Passport Session Serialization: How serializeUser and deserializeUser Keep Users Authenticated
Passport's serializeUser and deserializeUser bridge the gap between login and subsequent requests. Here is how they work, a minimal example, and the trade-offs.
27 Aug 2025, 20:47 UTC

The disappearing user after login
You call req.login(), the strategy succeeds, and the response goes out. Then the browser hits /profile and req.user is undefined. The session cookie is present, but Passport has no idea who the user is. The missing link is almost always the pair of callbacks that Passport expects you to define: serializeUser and deserializeUser.
What the two callbacks actually do
Passport does not store user records. After a strategy's verify callback confirms credentials, Passport calls serializeUser to decide what piece of data should be persisted in the session. In most applications that is the database primary key, not the whole user document.
On every subsequent request, Passport reads that stored value from the session and passes it to deserializeUser. Your callback turns that value back into a user object and attaches it to req.user. If deserializeUser returns null or false, Passport treats the request as unauthenticated.
A minimal worked example
The following example uses Express, express-session, and passport-local. Run the install command in your project root; it requires Node.js and npm already installed. The callback signatures shown have been stable across recent major releases of these libraries.
npm install express express-session passport passport-local
Assume a simple in-memory user list for illustration. In production this would be a database query.
const express = require('express');
const session = require('express-session');
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
// Placeholder user store
const users = [
{ id: 1, username: 'alice', password: 'wonderland', active: true }
];
passport.use(new LocalStrategy((username, password, done) => {
const user = users.find(u => u.username === username);
if (!user || user.password !== password) {
return done(null, false);
}
return done(null, user);
}));
// Decide what to store in the session
passport.serializeUser((user, done) => {
done(null, user.id);
});
// Rebuild req.user on later requests
passport.deserializeUser((id, done) => {
const user = users.find(u => u.id === id);
if (!user || !user.active) {
return done(null, false); // forces logout
}
done(null, user);
}));
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(session({ secret: 'replace-this', resave: false, saveUninitialized: false }));
app.use(passport.initialize());
app.use(passport.session());
app.post('/login', passport.authenticate('local'), (req, res) => {
res.send('logged in');
});
app.get('/profile', (req, res) => {
if (!req.user) return res.status(401).send('anonymous');
res.send(`hello ${req.user.username}`);
});
app.listen(3000);
Expected checks: after a successful POST /login, a GET /profile request should return the username. If you change deserializeUser to always call done(null, false), the same GET /profile should return a 401, demonstrating the failure path.
The core trade-off: database load vs. session freshness
Serializing only the user ID means the database is hit once during login and once per request during deserialization. That is a deliberate trade: you accept a query on every authenticated request in exchange for a small session payload. Storing the entire user object in the session avoids the query, but it inflates the session store, risks leaking sensitive fields if the store is readable, and freezes user data at login time.
| Approach | Session size | DB queries | Staleness risk |
|---|---|---|---|
| Store user ID only | Small | One per request | Low if re-validated |
| Store full user object | Large | Once at login | High |
Limitations and failure modes to plan for
Because serialization happens only at login, the session can outlive the user record. If an account is deleted or disabled after login, an old session will still authenticate unless deserializeUser checks the current state of the user. Returning false from deserializeUser is the supported way to reject a stale session.
Session store migrations are another sharp edge. If you switch from the default MemoryStore to Redis or another store without preserving existing session IDs, deserializeUser will receive values it cannot resolve, and users will be logged out unexpectedly. Plan a cutover that either keeps old IDs valid or accepts a forced re-login.
Practical verification: add a temporary route that logs req.session.passport and req.user. Confirm the stored value is an ID, not a document. Then disable a user in your store and verify that the next request no longer has req.user.
Actionable takeaways
- Serialize the smallest stable identifier, usually the primary key.
- In
deserializeUser, re-query the user store and returnfalsewhen the account is missing, disabled, or otherwise invalid. - Choose a durable session store (for example connect-redis) before deploying, because the default MemoryStore loses sessions on restart.
- Treat session payloads as sensitive: never store password hashes or large profile blobs.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.