Passport's Local Strategy Done Right: Verify Callbacks and What Actually Goes in the Session
Passport's local strategy leaves two decisions to you: what the verify callback returns and what goes into the session. Get both right—id-only serialization, single-purpose verify—and the rest is just routes.
20 Nov 2025, 16:43 UTC

If you've wired up Passport for the first time, you probably hit the same wall everyone does: login works, the redirect happens, and then req.user is undefined on the next request — or worse, your entire user record, password hash included, is sitting in the session store. The library's flexibility is the problem. Passport deliberately leaves two decisions to you — what the verify callback returns and what gets serialized into the session — and the defaults in most tutorials are sloppy about both.
The thesis here is simple: keep the verify callback responsible for exactly one thing (credential checking), and keep the session payload as small as possible (an identifier, nothing more). Everything else follows from those two choices.
How the pieces fit together
Passport is middleware for Express/Connect that authenticates requests through pluggable strategies. passport-local handles the classic username/password form. The flow has three distinct phases that are easy to conflate:
- Verify — runs once per login attempt. Your callback receives the submitted credentials and decides whether they're valid.
- Serialize — runs once per successful login. Decides what gets stored in the session.
- Deserialize — runs on every subsequent authenticated request. Rebuilds
req.userfrom whatever serialize stored.
A quick way to internalize this: add a console.log inside both serializeUser and deserializeUser, log in, then refresh a page a few times. You'll see serialize fire exactly once and deserialize fire on every request. That asymmetry is the key to the design decisions below.
The verify callback: one job, three outcomes
The verify callback signals its result through the done callback, and the three calling conventions matter:
done(null, user)— credentials valid, proceed to login.done(null, false, { message })— credentials invalid. This is a normal outcome, not an error.done(err)— something broke (database down, bug). This becomes a 500.
Password verification belongs inside this callback, using a slow hash like bcrypt or argon2. Passport never touches password storage — that's entirely on you. A typical implementation (assuming Node 18+, Express 4, and a users table with a bcrypt hash):
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const bcrypt = require('bcrypt');
passport.use(new LocalStrategy(
async (username, password, done) => {
try {
const user = await db.findUserByUsername(username);
if (!user) {
return done(null, false, { message: 'Invalid credentials' });
}
const match = await bcrypt.compare(password, user.passwordHash);
if (!match) {
return done(null, false, { message: 'Invalid credentials' });
}
return done(null, user);
} catch (err) {
return done(err); // infrastructure failure -> 500
}
}
));Note the deliberate choice to return the same message for "no such user" and "wrong password" — distinguishing them lets attackers enumerate valid usernames. Also note that the strategy is async/await friendly as long as every path ends in exactly one done() call; forgetting a return before done is a classic source of double-callback bugs.
Serialize the ID, not the user
This is the decision that bites people later. The minimal, correct version:
passport.serializeUser((user, done) => {
done(null, user.id); // only the id goes into the session
});
passport.deserializeUser(async (id, done) => {
try {
const user = await db.findUserById(id);
done(null, user || false);
} catch (err) {
done(err);
}
});Storing the whole user object in the session is tempting — it saves a query per request — but it creates two real problems. First, session stores (Redis, the database, even the default in-memory store) now carry bloated records, potentially including fields you never meant to persist. Second, the data goes stale: change a user's role or disable their account, and the session copy keeps the old permissions until it expires.
The trade-off of the id-only approach is one database lookup per authenticated request. For most applications that's cheap and index-friendly, and it buys you fresh roles and immediate account revocation. If profiling later shows the lookup is hot, add a short-TTL cache in front of findUserById — don't solve it by fattening the session.
After wiring this up, verify it by inspecting your session store directly after a login. You should see only the serialized id in the passport portion of the session, not the full record. If you see more, your serializeUser is leaking.
Wiring order and failure handling
Two setup details cause outsized confusion. First, middleware order: passport.initialize() and passport.session() must come after your session middleware, or req.isAuthenticated() and friends will be undefined or silently wrong:
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));
app.use(passport.initialize());
app.use(passport.session());Second, decide how failures are reported. For server-rendered pages, passport.authenticate('local', { failureRedirect: '/login' }) is fine. For an API, use the custom callback form so you control the status code:
app.post('/login', (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) return next(err);
if (!user) return res.status(401).json({ message: 'Invalid credentials' });
req.logIn(user, (loginErr) => {
if (loginErr) return next(loginErr);
res.json({ id: user.id, username: user.username });
});
})(req, res, next);
});Test the wrong-password path explicitly: it should produce your 401 or redirect, never a 500. A 500 on bad credentials means a done(null, false) path accidentally became done(err).
Limitations worth knowing up front
This session-based design assumes server-side session storage, so it doesn't scale horizontally without a shared store like Redis, and it's a poor fit for pure APIs consumed by SPAs or mobile clients — those usually pair better with a token strategy such as passport-jwt. Also, Passport has had long maintenance lulls; before adopting, check that the versions of passport and passport-local you're installing are still receiving updates, and pin them. Treat the code above as a pattern to adapt against your installed versions, not as drop-in tested code.
The actionable takeaway: log in, then check three things — serialize ran once, deserialize runs per request, and the session store holds only an id. If all three hold, your Passport setup is built on the right two decisions, and the rest is just routes.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.