Authentication with Passport.js: Local Login, Google and GitHub OAuth, JWT and Session Stores
Key takeaways
Passport.js is authentication middleware for Node.js. It supports 500+ authentication strategies including local, OAuth, and OpenID, making it the standard for authentication.
Introduction
Passport.js is authentication middleware for Node.js. It’s extremely flexible and modular, supporting authentication via username/password, OAuth (Google, Facebook, Twitter), and 500+ other strategies.
Why Passport?
Manual authentication (complex):
// Sessions, cookies, OAuth flows, password hashing...
// Hundreds of lines of code per provider
With Passport:
passport.use(new LocalStrategy(verify));
app.post('/login', passport.authenticate('local'));
Passport itself does not authenticate anyone. It is an implementation of the Strategy design pattern applied to authentication: every strategy — local, Google, GitHub, JWT, SAML — implements the same narrow contract, and Passport’s job is to call the right strategy at the right time and normalize the result. That contract is a single callback, conventionally named done(err, user, info). A strategy calls done(null, user) on success, done(null, false, info) on a rejected credential, or done(err) on an unexpected failure (a database timeout, a malformed provider response). Because every strategy speaks this same three-argument language, your route handlers never need to know whether the user logged in with a password, a Google redirect, or a bearer token — passport.authenticate('local' | 'google' | 'jwt', ...) looks identical from the call site.
This matters more than it looks. Hand-rolling OAuth for a single provider means implementing an HTTPS-signed authorization request, generating and validating a CSRF-resistant state parameter, exchanging an authorization code for tokens over a server-to-server request, refreshing expired access tokens, and normalizing a provider-specific user-profile JSON shape that has nothing in common with the next provider’s shape. Multiply that by every provider you support and the surface area for subtle security bugs grows fast — a missing state check, for instance, opens the door to session-fixation style OAuth login CSRF. Community-maintained strategies (passport-google-oauth20, passport-github2, and hundreds more) absorb that provider-specific variance so your application code stays provider-agnostic. The trade-off is that you are now depending on third-party packages of uneven maintenance quality — some OAuth strategies on npm have not been touched in years, so before adopting one in production it is worth checking its last publish date, open issues, and whether it still targets the provider’s current OAuth endpoints.
Installation
npm install passport passport-local express-session
passport is the core engine; passport-local is just one strategy among hundreds and is installed separately by design, so that an API-only service never has to pull in strategies (and their dependencies) it will never use. express-session is not a Passport package at all — it is the general-purpose Express session middleware that Passport’s session support builds on top of. Keeping them separate is intentional: Passport can run entirely without sessions (see the JWT strategy in section 6), so session handling is opt-in rather than bundled.
Local Strategy (Username/Password)
Setup
const express = require('express');
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const session = require('express-session');
const bcrypt = require('bcrypt');
const app = express();
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
// Session configuration
app.use(session({
secret: 'your-secret-key',
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 24 * 60 * 60 * 1000, // 24 hours
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
}
}));
// Initialize Passport
app.use(passport.initialize());
app.use(passport.session());
// Configure Local Strategy
passport.use(new LocalStrategy(
async (username, password, done) => {
try {
// Find user
const user = await db.users.findOne({ username });
if (!user) {
return done(null, false, { message: 'Incorrect username' });
}
// Verify password
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return done(null, false, { message: 'Incorrect password' });
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
// Serialize user for session
passport.serializeUser((user, done) => {
done(null, user.id);
});
// Deserialize user from session
passport.deserializeUser(async (id, done) => {
try {
const user = await db.users.findById(id);
done(null, user);
} catch (error) {
done(error);
}
});
A few details in this setup are easy to get wrong, and every one of them has bitten real production apps.
resave: false and saveUninitialized: false are not cosmetic. resave: false stops Express from rewriting the session to the store on every single request even when nothing changed — with a database-backed store this alone can be the difference between one write per login and one write per page view. saveUninitialized: false stops Passport from creating (and persisting) an empty session for every anonymous visitor, which matters both for storage cost and for GDPR-style consent rules that treat a persisted session cookie as tracking.
serializeUser/deserializeUser run more often than most people expect. serializeUser runs once, at login, to decide what gets written into the session (almost always just the primary key — never the whole user object, since session payloads should stay small and shouldn’t go stale if the user’s profile changes). deserializeUser, on the other hand, runs on every single authenticated request, because the session only ever stores the id; Passport has to look the full user back up each time to populate req.user. That means an unindexed findById in deserializeUser becomes a per-request database round trip across your entire authenticated traffic — a very common, very invisible performance bug. It is also the source of the classic Failed to deserialize user out of session symptom: if the id type stored by serializeUser doesn’t match what deserializeUser looks up (a MongoDB ObjectId serialized as a string but queried as an object, for example, or a user row that was deleted), deserialization silently fails and the request falls through as unauthenticated rather than throwing a loud error — which makes it a frustrating bug to track down in production logs.
The default session store is a trap. Nothing above configures store: on the session() call, which means express-session falls back to its built-in MemoryStore. That store is explicitly documented as unfit for production — it leaks memory under sustained traffic, it is wiped on every restart or deploy, and (critically for anything running more than one Node process) it is not shared across instances, so a user load-balanced to a different server after login simply appears logged out. Section 9 below covers replacing it with Redis.
Login Route
app.post('/login', passport.authenticate('local', {
successRedirect: '/dashboard',
failureRedirect: '/login',
failureFlash: true,
}));
// Or with callback
app.post('/login', (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) {
return next(err);
}
if (!user) {
return res.status(401).json({ error: info.message });
}
req.logIn(user, (err) => {
if (err) {
return next(err);
}
return res.json({
message: 'Login successful',
user: { id: user.id, username: user.username }
});
});
})(req, res, next);
});
These two forms are not interchangeable, and picking the wrong one is a common source of confusion. The first (successRedirect/failureRedirect) is Passport’s shorthand: it calls req.logIn() for you internally and then issues a server-side redirect, which is a natural fit for a traditional server-rendered app where a failed login should re-render the login page with a flash message. The second, callback-style form gives you the raw (err, user, info) triple yourself and requires you to call req.logIn(user, callback) explicitly — this is the form you need for a JSON API, because you want to return a 401 with a structured error body instead of an HTTP redirect that a fetch/XHR client can’t meaningfully follow.
Two things worth calling out about req.logIn() specifically: it is what actually establishes the session (calling serializeUser and writing req.user), and as of Passport 0.6 it — like req.logout() — requires a callback and is asynchronous, which trips up code copied from older tutorials that call it without one. It is also the point where a security-conscious app should regenerate the session ID rather than reuse the pre-login session, to prevent session fixation attacks where an attacker plants a known session ID in a victim’s browser before they authenticate. Passport does not do this automatically; if your session middleware or app framework doesn’t already handle it, you may need to call req.session.regenerate() before req.logIn().
One more thing this route does not handle: CSRF protection. Passport authenticates the request; it has no opinion about whether the request itself was forged by a third-party site. A POST-based, cookie-session login/logout endpoint is a classic CSRF target and needs its own protection (a synchronizer token via csurf/csrf-csrf, or a double-submit cookie pattern) layered on top — don’t assume Passport is handling this for you.
Registration
app.post('/register', async (req, res) => {
try {
const { username, email, password } = req.body;
// Check if user exists
const existing = await db.users.findOne({ username });
if (existing) {
return res.status(409).json({ error: 'Username already exists' });
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user
const user = await db.users.create({
username,
email,
password: hashedPassword,
});
// Auto-login after registration
req.login(user, (err) => {
if (err) {
return res.status(500).json({ error: 'Login failed' });
}
res.status(201).json({
message: 'Registration successful',
user: { id: user.id, username: user.username }
});
});
} catch (error) {
res.status(500).json({ error: 'Registration failed' });
}
});
The “check, then create” pattern here (findOne followed by create) has a race condition: if two requests for the same username arrive close enough together, both can pass the findOne check before either has written its row, and you end up with a duplicate-username insert failure — or worse, a duplicate account, depending on your schema. The check is a UX nicety, not a correctness guarantee. The actual guarantee has to come from a unique index on the username/email column at the database level, with the create() call’s duplicate-key error caught and translated into the same 409 response. Treat the pre-check purely as a fast path for the common case.
The bcrypt cost factor — the 10 in bcrypt.hash(password, 10) — controls how many rounds of key stretching are applied, and it is a deliberate time/security trade-off: each increment roughly doubles the hashing time. 10 was a reasonable default years ago; as hardware gets faster, the recommended cost factor drifts upward (many teams now default to 12), because the whole point of the cost factor is to keep offline brute-force attacks against a leaked password-hash database expensive even as attacker hardware improves. It’s worth revisiting this number periodically rather than treating it as a fixed constant.
Logout
app.post('/logout', (req, res) => {
req.logout((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.json({ message: 'Logged out successfully' });
});
});
req.logout() clears req.user and removes the user id from the session object, but — depending on your Passport version and configuration — it does not always fully destroy the underlying session record in the store. For a genuinely clean logout (important if the session might contain other sensitive state, or if you want to guarantee the session ID itself can never be replayed) it’s common to follow it with req.session.destroy() and then clear the cookie explicitly with res.clearCookie('connect.sid'). Without that, a stale session document can linger in Redis/Mongo/etc. until its TTL expires, even though the user can no longer use it to authenticate.
Protected Routes
// Middleware to check authentication
function ensureAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
res.status(401).json({ error: 'Not authenticated' });
}
// Protected route
app.get('/dashboard', ensureAuthenticated, (req, res) => {
res.json({
message: 'Welcome to dashboard',
user: req.user,
});
});
// Optional authentication
app.get('/profile/:id', (req, res) => {
if (req.isAuthenticated()) {
// Show full profile
res.json({ profile: 'full', user: req.user });
} else {
// Show public profile
res.json({ profile: 'public' });
}
});
req.isAuthenticated() is only meaningful if the middleware order upstream is correct: express-session must run before passport.initialize(), which must run before passport.session(), which must run before any route that calls isAuthenticated(). Get that order wrong and req.isAuthenticated() will always report false regardless of a valid session cookie, which looks exactly like a login bug but is actually a middleware-ordering bug.
The other place this silently breaks is a decoupled frontend — a React/Vue SPA served from a different origin than the API. Session auth relies on the browser sending the session cookie with each request, and by default fetch/axios do not send cookies cross-origin. You need credentials: 'include' (or withCredentials: true) on the client and a CORS configuration on the server that sets Access-Control-Allow-Credentials: true with an explicit (not wildcard) Access-Control-Allow-Origin. Miss either half and every request looks unauthenticated even though the login call itself appeared to succeed.
sequenceDiagram
participant Browser
participant Express as Express + Passport
participant Store as Session Store
Browser->>Express: POST /login (username, password)
Express->>Express: LocalStrategy verifies credentials
Express->>Express: serializeUser(user) -> user.id
Express->>Store: write session { userId }
Express-->>Browser: Set-Cookie: connect.sid
Browser->>Express: GET /dashboard (Cookie: connect.sid)
Express->>Store: read session by cookie id
Store-->>Express: { userId }
Express->>Express: deserializeUser(userId) -> req.user
Express-->>Browser: 200 OK (authenticated response)
Google OAuth
npm install passport-google-oauth20
Before the code, it helps to have the overall OAuth 2.0 Authorization Code flow in mind, since every step below maps onto it: your server redirects the browser to Google with your clientID and the scopes you’re requesting; the user authenticates and consents on Google’s site, never yours; Google redirects the browser back to your callbackURL with a short-lived authorization code; and passport-google-oauth20 exchanges that code server-to-server for an access token and the user’s profile. The single most common setup mistake is a callbackURL mismatch — Google (and every other OAuth provider) requires the redirect URI to match exactly what’s registered in the provider console, including protocol, port, and trailing slash. A localhost callback that works in development and then silently differs from what’s registered for production is a frequent source of redirect_uri_mismatch errors.
const GoogleStrategy = require('passport-google-oauth20').Strategy;
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/auth/google/callback',
},
async (accessToken, refreshToken, profile, done) => {
try {
// Find or create user
let user = await db.users.findOne({ googleId: profile.id });
if (!user) {
user = await db.users.create({
googleId: profile.id,
email: profile.emails[0].value,
name: profile.displayName,
avatar: profile.photos[0].value,
});
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
// Routes
app.get('/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/dashboard');
}
);
Two production gotchas are worth flagging in this handler. First, profile.emails[0].value assumes the array is never empty — for providers that allow a user to hide their email (GitHub in particular, see below), that assumption throws a TypeError deep inside your verify callback. Always guard optional profile fields rather than indexing into them directly. Second, and more subtle: findOne({ googleId: profile.id }) only finds users who originally signed up through Google. If someone first registered with the local strategy using [email protected] and later clicks “Sign in with Google” using the same email, this lookup won’t match her existing account — it will silently create a second, disconnected account with the same email address. The fix is to also check for an existing user by verified email and link the Google identity to that account rather than blindly creating a new one; section 7 below discusses a cleaner schema for this.
There’s also a session-level requirement that’s easy to overlook: even though this flow doesn’t use JWTs, passport.authenticate('google', ...) still needs an active session to store OAuth state across the redirect round-trip to Google and back — so the express-session + passport.session() middleware from section 2 has to be wired up even in an app that otherwise leans on OAuth alone, or the callback will fail unpredictably.
GitHub OAuth
npm install passport-github2
const GitHubStrategy = require('passport-github2').Strategy;
passport.use(new GitHubStrategy({
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: '/auth/github/callback',
},
async (accessToken, refreshToken, profile, done) => {
try {
let user = await db.users.findOne({ githubId: profile.id });
if (!user) {
user = await db.users.create({
githubId: profile.id,
username: profile.username,
name: profile.displayName,
avatar: profile.photos[0].value,
});
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
// Routes
app.get('/auth/github',
passport.authenticate('github', { scope: ['user:email'] })
);
app.get('/auth/github/callback',
passport.authenticate('github', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/dashboard');
}
);
The account-linking and optional-field pitfalls from the Google strategy apply here too, plus one that’s specific to GitHub: by default, GitHub does not include a user’s email in the base profile if they’ve set it to private, which is common. Requesting the user:email scope (as shown above) is necessary but not always sufficient — some accounts still require a separate call to GitHub’s /user/emails endpoint to retrieve a verified address. If your registration flow assumes profile.emails is always populated, budget for the case where it isn’t, and decide up front whether an email-less GitHub signup is acceptable for your app or should be rejected with a prompt to make an email public.
JWT Strategy
npm install passport-jwt jsonwebtoken
Everything up to this point has been session-based: the server holds state (the session) and the browser holds a small opaque reference to it (the cookie). JWTs invert that — the token itself carries the claims, signed so the server can verify it wasn’t tampered with, and the server holds no per-session state at all. That distinction drives a real architectural decision, not just a style preference:
- Sessions give you instant, centralized revocation — deleting the session record logs the user out immediately, everywhere — at the cost of needing a shared store (Redis, typically) the moment you run more than one server process, and being a natural fit for cookie-based browser clients but an awkward one for native mobile apps or service-to-service calls.
- JWTs need no shared store and scale horizontally for free, and they’re the natural fit for SPAs, mobile clients, and microservice-to-microservice auth — but revocation is hard by construction. A JWT is valid until it expires, full stop; there is no “delete the session” button. Production systems work around this with short access-token lifetimes (minutes, not the
7dshown below) paired with a longer-lived, server-tracked refresh token that can be revoked, or with an explicit denylist for the rare “kill this token now” case (e.g. a stolen device).
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
const jwt = require('jsonwebtoken');
// Configure JWT Strategy
passport.use(new JwtStrategy({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
},
async (payload, done) => {
try {
const user = await db.users.findById(payload.sub);
if (!user) {
return done(null, false);
}
return done(null, user);
} catch (error) {
return done(error, false);
}
}
));
// Login route (generate JWT)
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
const user = await db.users.findOne({ username });
if (!user || !await bcrypt.compare(password, user.password)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate JWT
const token = jwt.sign(
{ sub: user.id, username: user.username },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
res.json({ token, user: { id: user.id, username: user.username } });
});
// Protected route
app.get('/api/profile',
passport.authenticate('jwt', { session: false }),
(req, res) => {
res.json({ user: req.user });
}
);
The session: false option on the protected route is the tell that this strategy skips Passport’s session machinery entirely — there’s no serializeUser/deserializeUser round trip here; the JWT strategy’s verify callback runs fresh on every request directly against the decoded token payload. Note the sub claim convention (the JWT-spec standard name for “subject,” i.e. the user id) — using it instead of a custom field name keeps the token interoperable with other JWT tooling that expects standard claims.
A JWT’s payload is signed, not encrypted — anyone who intercepts the token can base64-decode and read it, they just can’t forge a valid signature for a modified one. Never put a password, a full profile, or anything you wouldn’t want visible in a browser’s dev tools into the payload. And the 7d expiry above is a convenience/risk trade-off worth reconsidering for anything beyond a prototype: a stolen 7-day token is valid for a week with no way to revoke it mid-flight, which is exactly the scenario a short-lived-access-token-plus-refresh-token pattern exists to avoid.
Multiple Strategies
// Local + Google + GitHub
passport.use('local', new LocalStrategy(/* ... */));
passport.use('google', new GoogleStrategy(/* ... */));
passport.use('github', new GitHubStrategy(/* ... */));
// Login with local
app.post('/login', passport.authenticate('local'));
// Login with Google
app.get('/auth/google', passport.authenticate('google'));
// Login with GitHub
app.get('/auth/github', passport.authenticate('github'));
Once an app supports more than one strategy, the account-linking problem from sections 4 and 5 stops being an edge case and becomes a schema decision. Storing googleId, githubId, and a local password hash as separate columns directly on the users table works for a demo, but it doesn’t scale past two or three providers and makes “the same person signed in with two different providers” hard to represent cleanly. A more durable pattern is a separate identities table — (provider, providerId, userId) — where each row links one external identity to one internal user record. Logging in then becomes “look up the identity by (provider, providerId), and if found, load the linked user”; adding a new provider later is a new strategy plus new identity rows, not a new column and a data migration.
Custom Callback
app.post('/login', (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) {
return res.status(500).json({ error: 'Internal error' });
}
if (!user) {
return res.status(401).json({ error: info.message || 'Login failed' });
}
req.logIn(user, (err) => {
if (err) {
return res.status(500).json({ error: 'Session error' });
}
// Custom response
return res.json({
success: true,
user: {
id: user.id,
username: user.username,
email: user.email,
},
token: generateToken(user), // If using JWT
});
});
})(req, res, next);
});
Notice the error responses here deliberately return generic messages ('Internal error', 'Session error') rather than err.message. That’s not laziness — forwarding a raw database or internal error string to the client is a common information-leakage vector, potentially revealing schema details, internal hostnames, or stack traces to an attacker probing the login endpoint. Log the real error server-side; return something generic to the client.
The commented token: generateToken(user) line hints at a genuinely common hybrid pattern: an app that serves both a browser frontend (which wants a cookie-based session for its own pages) and a mobile app or public API (which wants a bearer token) from the same login endpoint. There’s nothing wrong with issuing both a session cookie and a JWT from one successful login — just be deliberate about it, since now you have two independent credential lifetimes to reason about and revoke.
Session Store (Production)
Section 2 flagged this already, but it’s worth stating plainly before the fix: the default MemoryStore is unsuitable for production for three concrete reasons, not just a documentation warning. It grows without bound as sessions accumulate, since nothing evicts old entries proactively. It is wiped on every process restart, meaning a deploy or a crash logs out your entire user base at once. And it lives entirely in one Node process’s memory, so the instant you run more than one server instance (a cluster, multiple containers, autoscaling), a user’s session only exists on whichever instance handled their login — load balance them to a different instance on their next request and they appear logged out for no visible reason. All three problems disappear once sessions live in a shared, persistent store.
With Redis
npm install connect-redis redis
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
// Create Redis client
const redisClient = createClient({
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
});
redisClient.connect();
// Configure session with Redis
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 24 * 60 * 60 * 1000,
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
}
}));
Redis is the standard choice here because it gives every server instance the same view of session state and supports a native per-key TTL, which connect-redis uses to expire session documents automatically in line with cookie.maxAge — you don’t need a separate cleanup job. One operational detail that’s easy to miss: the redis client emits an error event on connection loss, and if nothing is listening for it, Node treats it as an unhandled error and can crash the process. Always attach a handler (redisClient.on('error', (err) => { ... })) before calling connect(), and consider namespacing session keys (connect-redis supports a prefix option) if the same Redis instance is shared with other data, so a FLUSHALL run for an unrelated reason doesn’t nuke every logged-in user.
Remember Me
// Login with remember me
app.post('/login', (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err || !user) {
return res.status(401).json({ error: 'Login failed' });
}
req.logIn(user, (err) => {
if (err) {
return res.status(500).json({ error: 'Session error' });
}
// Set longer expiry for remember me
if (req.body.rememberMe) {
req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days
}
res.json({ success: true, user });
});
})(req, res, next);
});
Extending cookie.maxAge is the simplest possible implementation of “remember me,” but it’s worth being deliberate about the security trade-off: a 30-day session cookie is a 30-day window during which a stolen device or a leaked cookie stays useful to an attacker, with no re-authentication checkpoint in between. Two refinements are common in production: pairing a long maxAge with rolling: true in the express-session config (so the expiry slides forward on activity rather than being a fixed 30 days from login), and treating “remember me” sessions as lower-trust for sensitive actions — requiring a fresh password confirmation before, say, changing an email address or a payment method, even if the long-lived session is still technically valid.
Real-World Example
const express = require('express');
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const session = require('express-session');
const bcrypt = require('bcrypt');
const app = express();
// Middleware
app.use(express.json());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
}));
app.use(passport.initialize());
app.use(passport.session());
// Local Strategy
passport.use(new LocalStrategy(
{ usernameField: 'email' },
async (email, password, done) => {
try {
const user = await db.users.findOne({ email });
if (!user) {
return done(null, false, { message: 'User not found' });
}
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
return done(null, false, { message: 'Invalid password' });
}
return done(null, user);
} catch (error) {
return done(error);
}
}
));
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser(async (id, done) => {
try {
const user = await db.users.findById(id);
done(null, user);
} catch (error) {
done(error);
}
});
// Routes
app.post('/api/register', async (req, res) => {
const { email, password, name } = req.body;
const hashedPassword = await bcrypt.hash(password, 10);
const user = await db.users.create({
email,
password: hashedPassword,
name,
});
req.login(user, (err) => {
if (err) return res.status(500).json({ error: err.message });
res.json({ user: { id: user.id, email: user.email, name: user.name } });
});
});
app.post('/api/login', passport.authenticate('local'), (req, res) => {
res.json({ user: req.user });
});
app.post('/api/logout', (req, res) => {
req.logout(() => {
res.json({ message: 'Logged out' });
});
});
app.get('/api/me', (req, res) => {
if (!req.isAuthenticated()) {
return res.status(401).json({ error: 'Not authenticated' });
}
res.json({ user: req.user });
});
app.listen(3000);
This example is worth reading end-to-end rather than skimming: it’s every piece from the sections above — email-based local auth, session wiring, serialize/deserialize, register/login/logout/me — assembled into one minimal but coherent app. Note that it uses { usernameField: 'email' } to tell passport-local to read req.body.email instead of its default username field, which is the standard way to adapt the local strategy to whatever field name your actual login form uses without touching the strategy’s internals.
Session cookies, login throttling and HTTPS behind a proxy
Session cookie settings
app.use(session({
secret: process.env.SESSION_SECRET, // Use env var
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 24 * 60 * 60 * 1000,
httpOnly: true, // Prevent XSS
secure: true, // HTTPS only in production
sameSite: 'strict', // CSRF protection
}
}));
httpOnly: true matters because it’s what stops a successful XSS payload from reading the session cookie via document.cookie — without it, one injected script anywhere on the page can exfiltrate every logged-in user’s session. sameSite: 'strict' is a strong CSRF mitigation, but read it carefully before copying it into an app that also does OAuth: strict prevents the cookie from being sent on the top-level navigation that brings the user back from an external OAuth provider’s redirect, which can break the callback flow in section 4 depending on browser and exact navigation type. Many apps use sameSite: 'lax' instead specifically to keep OAuth redirects working, and add explicit CSRF tokens on state-changing POST routes to cover the gap lax leaves open.
Rate-limit login attempts
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: 'Too many login attempts',
});
app.post('/login', loginLimiter, passport.authenticate('local'));
Without this, the local strategy’s bcrypt.compare check is the only thing standing between an attacker and an unlimited-attempt credential-stuffing or brute-force run against your login endpoint. Rate limiting by IP is a reasonable default but not sufficient on its own — a distributed attack spreads requests across many IPs, so pairing IP-based limits with account-based lockout (or a login-attempt counter tied to the username/email) closes a gap that pure IP throttling leaves open.
Redirecting to HTTPS without a redirect loop
if (process.env.NODE_ENV === 'production') {
app.use((req, res, next) => {
if (!req.secure) {
return res.redirect(`https://${req.headers.host}${req.url}`);
}
next();
});
}
This redirect-to-HTTPS middleware has a well-known failure mode behind a reverse proxy or CDN (Nginx, a load balancer, Cloudflare, Heroku): the proxy terminates TLS and forwards the request to your Node process over plain HTTP internally, so req.secure is false even though the original client connection was HTTPS — which sends every request into an infinite redirect loop. The fix is app.set('trust proxy', 1) (or the correct hop count for your setup), which tells Express to trust the X-Forwarded-Proto header the proxy sets and derive req.secure from that instead of the raw socket. This same setting is also what makes cookie.secure: true behave correctly behind a proxy — without it, Express thinks every connection is plain HTTP and refuses to set the cookie at all, which looks identical to a session bug but is actually a missing one-line trust-proxy configuration.
Troubleshooting Common Issues
A short reference for the failure modes covered above, grouped by symptom:
- “Failed to deserialize user out of session” / users randomly logged out. Almost always a mismatch between what
serializeUserstored and whatdeserializeUserlooks up — a deleted user, a changed id format, or a database migration that didn’t account for existing sessions. Check the id type on both sides first. - Login works locally but sessions don’t persist in production. Nearly always a missing
app.set('trust proxy', 1)behind a reverse proxy/CDN, causingcookie.secure: trueto silently refuse to set the cookie, orreq.secureto befalseand trigger an HTTPS redirect loop. - OAuth callback fails with a redirect/mismatch error. The
callbackURLregistered in the provider’s developer console doesn’t exactly match the one your app sends — check protocol, host, port, and trailing slash between environments. - Session-based auth works from Postman/curl but not from the frontend. The frontend is almost certainly not sending credentials cross-origin. Confirm
credentials: 'include'on the client and a non-wildcardAccess-Control-Allow-OriginplusAccess-Control-Allow-Credentials: trueon the server. - OAuth login breaks intermittently with
sameSite: 'strict'cookies. Switch tosameSite: 'lax'for the session cookie and rely on explicit CSRF tokens for other state-changing routes;strictcan drop the cookie on the return navigation from an external OAuth redirect. - A user who signed up locally can’t “link” a Google/GitHub login with the same email. This is expected with the naive
findOne({ googleId })pattern in sections 4-5 — it only matches users who originally registered through that provider. Implement explicit account linking by verified email, or adopt theidentitiestable pattern from section 7.
Decisions to settle before adding more strategies
Choose sessions for web apps and JWT for APIs, but settle the revocation trade-off before you pick. Session cookies depend on sameSite, secure, and trust proxy working together, and mistakes there usually surface only in production behind a proxy or CDN. serializeUser and deserializeUser run on every authenticated request, so keep them lean and store a stable id. Design account linking and the identity schema before adding more providers, because “find or create by providerId” quietly creates duplicate accounts, and rate limit logins by both IP and account.
Frequently Asked Questions (FAQ)
Q. When should I reach for Passport instead of writing auth by hand?
A. As soon as you need more than one way to authenticate a user — a password plus even one OAuth provider — or you expect to add providers later. Passport’s strategy pattern means adding Google or GitHub login later is a new passport.use() call and a couple of routes, not a rewrite of your auth code.
Q. Do I need express-session if I’m only using the JWT strategy?
A. Not for the JWT routes themselves, since they run with session: false. You still need session middleware if the same app also does OAuth (see section 4), because the OAuth handshake stores its CSRF-protecting state value in the session across the redirect to the provider and back.
Q. Where can I go deeper on the pieces this guide touches only briefly?
A. See the linked bcrypt, JWT, and Express guides below for a deeper treatment of password hashing, access/refresh token design, and the underlying middleware model Passport plugs into.
Related Articles
- Node.js Authentication and Security: JWT, bcrypt, and Sessions
- Express.js for REST APIs: Routing, Middleware Order, Error Handling and What Changed in Express 5
- JWT Authentication Guide | Access Tokens · Refresh Tokens