When to Customize Passport’s serializeUser and deserializeUser Callbacks
Learn how Passport’s serializeUser and deserializeUser callbacks control what lives in the session and how to add a caching layer to avoid costly DB lookups on every request.
05 Aug 2025, 19:49 UTC

Problem: logged‑in user vanishes after the first request
You’ve added Passport to an Express API, configured a LocalStrategy, and the login endpoint returns 200. Yet on the very next authenticated request req.user is undefined. The symptom usually points to a mismatch between what Passport stores in the session and how it retrieves that data later.
Thesis: mastering the serialize/deserialize pair lets you keep Passport’s flexibility while deciding whether the default session approach fits your scale or needs a custom store.
How Passport ties a user to the session
When passport.authenticate() succeeds, Passport calls the application‑provided serializeUser(user, done). The first argument you pass to done(null, id) becomes the value stored in the session cookie (connect.sid). On every subsequent request, Passport invokes deserializeUser(id, done); whatever you pass to done(null, userObject) ends up on req.user. No other middleware touches this flow.
When to customize the callbacks
- Reducing database load. If
deserializeUserperforms a costly lookup on each request, add a caching layer (e.g., an LRU map or Redis) inside the callback. - Storing extra data. You might want to attach permissions or tenant info to
req.user- Stateless hybrids. When you issue JWTs alongside Passport, you can skip
express-sessionentirely and letdeserializeUserverify the token and return a user payload. - Stateless hybrids. When you issue JWTs alongside Passport, you can skip
Worked example: adding a simple LRU cache to deserializeUser
Assume you already have a working LocalStrategy and an in‑memory user map for demo purposes. The goal is to keep the user object in memory after the first deserialize call, avoiding a DB hit on every request.
// install: npm i lru-cache
const LRU = require('lru-cache');
const userCache = new LRU({ max: 500 });
// serializeUser – store only the user ID
passport.serializeUser((user, done) => {
done(null, user.id);
});
// deserializeUser – try cache, fall back to DB
passport.deserializeUser(async (userId, done) => {
let user = userCache.get(userId);
if (!user) {
// replace with your actual data‑access call
user = await getUserById(userId); // may throw
if (user) userCache.set(userId, user);
}
done(null, user || null);
});
// middleware order (Express 4/5)
app.use(require('express-session')({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' }
}));
app.use(require('passport').initialize());
app.use(require('passport').session());
// example route
app.post('/login', passport.authenticate('local', { failureRedirect: '/login' }), (req, res) => {
res.redirect('/');
});
To verify the cache is working:
- Run the app (
node index.js) withNODE_ENV=development. - Log in via
POST /loginand note the response time. - Make a second authenticated request (e.g.,
GET /profile) and compare latency; it should be noticeably lower if the DB call is expensive. - Open browser devtools → Application → Cookies →
connect.sid. The cookie value should be a base64 string that decodes to just the user ID, not the full user object.
Trade‑off and limitation
Adding a cache introduces extra state that must be invalidated when user data changes (e.g., password update). If you forget to bust the cache, stale permissions could persist. A practical check is to listen to your user‑update events and call userCache.delete(userId) (or clear the whole cache) to keep consistency.
Another limitation: deserializeUser still runs on every request, so even a cached lookup adds a small overhead. For ultra‑low‑latency services you might replace the session entirely with a stateless JWT approach, but then you lose Passport’s built‑in flash messages and easy strategy swapping unless you add glue code.
Actionable closing
Start by measuring the time spent in your current deserializeUser (e.g., with clinic.js or a simple console.time wrapper). If it exceeds a few milliseconds, introduce a cache as shown and re‑measure. Verify that the session cookie still holds only an ID and that logging out (req.logout()) removes the session. Once latency is acceptable, document any cache‑invalidations needed for your user‑mutation workflows.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.