🛂 Passport.js Strategies
You just learned the OAuth dance. Now meet the partner that makes Node.js dance it flawlessly. Passport.js is authentication middleware that hides the ceremony of dozens of login methods behind one clean, consistent pattern — so your app code stays focused on your app.
Week 9 · Thursday: OAuth and Social Login · Lecture 2
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain what a Passport strategy is and how the
donecallback signals success, failure, or error - Wire up Passport in an Express app with
initialize()andsession() - Describe the
serializeUser/deserializeUsersession cycle and why only the user ID goes in the session - Implement the Local strategy with hashed passwords
- Configure the
passport-google-oauth20strategy for social login - Combine multiple named strategies and protect routes with authentication guards
Estimated Time: 70 minutes
Practice: Build a Local-strategy verify function and a route guard, then add a Google strategy.
In This Lesson
What Is Passport.js?
Passport is authentication middleware for Node.js. Its genius is a single, uniform interface across every authentication method — username/password, Google, GitHub, SAML, JWT, magic links — each implemented as a pluggable strategy. Your route code always looks the same; only the strategy underneath changes.
🔌 Analogy: the universal power adapter
Think of Passport as the travel adapter in your bag. The adapter's job never changes — plug your device into one side, the wall socket into the other. What changes per country is the little plug shape you clip on. A Passport strategy is that clip: passport-local for passwords, passport-google-oauth20 for Google, passport-jwt for tokens. Swap the clip, keep the adapter.
There are three core ideas you'll use in every Passport app:
- Strategy — a plugin that knows how to verify one kind of credential.
- Verify callback — your function that a strategy calls to look up or create the user, then reports back via
done(). - Serialize / deserialize — how Passport stores a logged-in user in the session and restores them on later requests.
Setting Up Passport
Passport rides on top of Express sessions. Install the pieces, configure a session store, then initialize Passport.
// npm install express express-session passport
const express = require('express');
const session = require('express-session');
const passport = require('passport');
const app = express();
app.use(express.urlencoded({ extended: false }));
// 1. Sessions must come BEFORE passport.session()
app.use(session({
secret: process.env.SESSION_SECRET, // strong, random, from the environment
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true, // JS can't read the cookie
secure: process.env.NODE_ENV === 'production', // HTTPS-only in prod
sameSite: 'lax', // CSRF hardening
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// 2. Initialize Passport and connect it to the session
app.use(passport.initialize());
app.use(passport.session());
💡 Order matters
passport.session() is itself middleware that reads the session to restore the user. It must be registered after express-session. Get the order wrong and req.user will silently be undefined on every request.
serializeUser & deserializeUser
When a user logs in, Passport needs to remember them across requests without stuffing the whole user object into the cookie. It solves this with two functions you provide.
serializeUserruns once at login. It decides the minimal token to store in the session — almost always just the user's database ID.deserializeUserruns on every subsequent request. It takes that ID back out of the session and loads the full user, attaching it toreq.user.
// Store ONLY the id in the session — keep the cookie small and safe
passport.serializeUser((user, done) => {
done(null, user.id);
});
// On each request, turn the stored id back into a full user object
passport.deserializeUser(async (id, done) => {
try {
const user = await User.findById(id);
done(null, user); // becomes req.user
} catch (err) {
done(err);
}
});
The Local Strategy
The Local strategy (passport-local) is classic username-and-password auth against your own database. Your verify callback finds the user, compares the password against a stored hash, and calls done().
// npm install passport-local bcrypt
const LocalStrategy = require('passport-local').Strategy;
const bcrypt = require('bcrypt');
const User = require('./models/User');
passport.use(new LocalStrategy(
{ usernameField: 'email' }, // use "email" instead of "username"
async (email, password, done) => {
try {
const user = await User.findOne({ email });
if (!user) {
// Generic message — don't reveal WHICH part was wrong
return done(null, false, { message: 'Invalid email or password' });
}
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return done(null, false, { message: 'Invalid email or password' });
}
return done(null, user); // success!
} catch (err) {
return done(err); // unexpected error
}
}
));
// The login route just names the strategy
app.post('/login', passport.authenticate('local', {
successRedirect: '/profile',
failureRedirect: '/login'
}));
✅ Reading the done callback
The three signatures are the whole contract:
done(null, user)→ authentication succeeded.done(null, false, info)→ credentials were bad (a normal failure, not a crash).done(err)→ something broke (DB down, etc.) — routed to your error handler.
⚠️ Never store raw passwords
Always hash with a slow, salted algorithm — bcrypt or argon2 — and compare with the library's timing-safe compare. Storing plaintext (or a fast hash like MD5/SHA-1) means one database leak exposes every user's password.
The Google OAuth Strategy
Here's where last lesson's OAuth theory becomes one tidy config object. The passport-google-oauth20 strategy runs the entire Authorization Code flow for you — the redirect, the consent screen, the token exchange — and hands your verify callback a ready-made profile.
// npm install passport-google-oauth20
const GoogleStrategy = require('passport-google-oauth20').Strategy;
passport.use(new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET, // server-side secret — safe here
callbackURL: '/auth/google/callback', // must match the registered URI
scope: ['profile', 'email']
},
// accessToken/refreshToken are the provider's tokens; profile is normalized user data
async (accessToken, refreshToken, profile, done) => {
try {
// Look the user up by their Google id, or create them on first login
let user = await User.findOne({ googleId: profile.id });
if (!user) {
user = await User.create({
googleId: profile.id,
name: profile.displayName,
email: profile.emails?.[0]?.value,
avatar: profile.photos?.[0]?.value
// no password — this is a social-only account
});
}
return done(null, user);
} catch (err) {
return done(err);
}
}
));
// Route 1: kick off the flow — Passport redirects to Google
app.get('/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
// Route 2: Google redirects back here; Passport finishes the token exchange
app.get('/auth/google/callback',
passport.authenticate('google', {
failureRedirect: '/login',
successRedirect: '/profile'
})
);
💡 Where did all the OAuth ceremony go?
Every step from the previous lesson still happens — building the /authorize URL, carrying state, POSTing to the token endpoint with the client_secret. The strategy just does it internally. Your clientSecret stays server-side because this whole strategy runs on your server, never in the browser.
Multiple & Named Strategies
Real apps offer several ways in. Register as many strategies as you like — each gets a name (defaulting to the package's name), and routes pick which to run. You can even hand authenticate an array of names to try in order.
// Give strategies explicit names when you need more than one of a kind
passport.use('local-login', new LocalStrategy({ usernameField: 'email' }, verifyLogin));
passport.use('local-admin', new LocalStrategy({ usernameField: 'email' }, verifyAdmin));
passport.use('google', new GoogleStrategy(googleOptions, verifyGoogle));
// Each route names the strategy it wants
app.post('/login', passport.authenticate('local-login', {
successRedirect: '/profile', failureRedirect: '/login'
}));
app.post('/admin/login', passport.authenticate('local-admin', {
successRedirect: '/admin', failureRedirect: '/admin/login'
}));
// Accept EITHER a JWT or an API key on an API route (first to succeed wins)
app.get('/api/data',
passport.authenticate(['jwt', 'apikey'], { session: false }),
(req, res) => res.json({ data: 'protected' })
);
📖 { session: false } for APIs
Token-based API auth is stateless — every request carries its own proof, so there's no session to create. Passing { session: false } tells Passport to skip serializeUser entirely. Use it for JWT and API-key strategies; leave it off for browser session logins.
Protecting Routes
Once a user is logged in, Passport exposes req.isAuthenticated() and req.user. Wrap protected routes in a tiny guard middleware.
// A reusable guard
function ensureAuthenticated(req, res, next) {
if (req.isAuthenticated()) return next();
res.redirect('/login');
}
// Apply it to any route that needs a logged-in user
app.get('/profile', ensureAuthenticated, (req, res) => {
res.send(`Welcome, ${req.user.name}!`);
});
// Logout is async in modern Passport (v0.6+): pass a callback
app.post('/logout', (req, res, next) => {
req.logout(err => {
if (err) return next(err);
res.redirect('/');
});
});
⚠️ req.logout changed
In Passport 0.6+, req.logout() is asynchronous and requires a callback. Old tutorials call req.logout() with no arguments — that throws now. Always pass the callback shown above.
Practice & Quiz
🏋️ Exercise 1: Write a Local verify callback
Goal: Complete verify(email, password, done) for a Local strategy. Look the user up by email, compare a bcrypt-hashed password, and call done correctly for the three outcomes.
const bcrypt = require('bcrypt');
async function verify(email, password, done) {
// TODO: find user, compare password, call done() appropriately
}
💡 Hint
Wrap the DB work in try/catch. A missing user or a bad password are both done(null, false, { message }). Only a thrown error is done(err). Use the same generic message for both failure cases.
✅ Solution
async function verify(email, password, done) {
try {
const user = await User.findOne({ email });
if (!user) {
return done(null, false, { message: 'Invalid email or password' });
}
const ok = await bcrypt.compare(password, user.password);
if (!ok) {
return done(null, false, { message: 'Invalid email or password' });
}
return done(null, user);
} catch (err) {
return done(err);
}
}
🏋️ Exercise 2: A role-checking guard
Goal: Write requireRole(role) — a middleware factory that returns a guard allowing only authenticated users whose req.user.role matches.
✅ Solution
function requireRole(role) {
return (req, res, next) => {
if (!req.isAuthenticated()) return res.redirect('/login');
if (req.user.role !== role) return res.status(403).send('Forbidden');
next();
};
}
// Usage:
app.get('/admin', requireRole('admin'), (req, res) => res.send('Admin area'));
Returning a middleware from a function lets you reuse the same logic with different roles — a common Express pattern.
🎯 Quick Quiz
Question 1: What does serializeUser typically store in the session?
Question 2: A user submits a wrong password. How should the verify callback respond?
Question 3: Why pass { session: false } when authenticating an API request with JWT?
Best Practices & Pitfalls
✅ Do
- Register
express-sessionbeforepassport.session() - Store only the user ID in the session; re-fetch the user in
deserializeUser - Hash passwords with bcrypt or argon2 and compare with the library's function
- Return generic error messages so you don't leak whether an account exists
- Keep every
clientSecretandSESSION_SECRETin environment variables - Use a persistent session store (Redis, Mongo) in production — not the default MemoryStore
❌ Don't
- Don't put the whole user object (or any secret) into the session cookie
- Don't forget the callback in
req.logout(cb)on Passport 0.6+ - Don't skip
{ session: false }on stateless API strategies - Don't reveal "user not found" vs "wrong password" — attackers use that to enumerate accounts
- Don't rely on the in-memory MemoryStore in production — it leaks memory and drops sessions on restart
Summary
🎉 Key Takeaways
- Passport wraps every login method in a uniform strategy interface
- Your verify callback signals results via
done(null, user),done(null, false, info), ordone(err) serializeUserstores just the ID;deserializeUserreloads the full user each request- The Local strategy checks a bcrypt hash;
passport-google-oauth20runs the whole OAuth flow for you - Register multiple named strategies and guard routes with
req.isAuthenticated()
📚 Additional Resources
- passportjs.org — Official documentation
- passportjs.org — Strategy directory (500+ strategies)
- passportjs.org — Google authentication guide
- OWASP — Authentication Cheat Sheet
🚀 What's Next?
You have the protocol and the library. In the final lesson of the day you'll put them together into a complete, production-shaped app: Implementing Social Login — user model, multi-provider config, account linking, and the routes that tie it all together.
🎉 Nicely done!
One adapter, many plugs. You can now add any login method without rewriting your app.