How Passport.js Serialization Works with Sessions and JWT Strategies
Learn what serializeUser and deserializeUser do, see a working local‑strategy example, and understand why you might keep them when switching to a stateless JWT approach.
21 Dec 2025, 06:00 UTC

The problem: losing user data after login
You have added Passport.js to an Express API, users can log in, but on the next request req.user is undefined and protected routes return 401. The symptom often looks like a mis‑configured strategy, but the root cause is usually what Passport does with the user object between requests.
Thesis
Passport’s serializeUser and deserializeUser callbacks decide what identifier is stored in the session and how the full user object is rebuilt on each request. Keeping these callbacks—even when you move to a stateless JWT strategy—lets you reuse Passport’s middleware chain without extra code, while misconfiguring them breaks authentication.
How serialization works with a session strategy
When you use a session‑based strategy such as passport-local, Passport invokes serializeUser after a successful login. The value you return (typically just a user ID) is stored in req.session.passport.user by the express-session middleware. On every subsequent request, Passport calls deserializeUser with that stored value; you must return the full user object (or false/null to signal failure). Passport then attaches the result to req.user.
Worked example: local strategy with in‑memory store
The following snippet shows a minimal Express app. Run it with node app.js after installing the dependencies:
const express = require('express');
const session = require('express-session');
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(session({ secret: '{{YOUR_SESSION_SECRET}}', resave: false, saveUninitialized: false }));
app.use(passport.initialize());
app.use(passport.session());
// In‑memory user store (for demo only)
const users = [{ id: 1, username: 'alice', password: 'secret' }];
passport.use(new LocalStrategy(
(username, password, done) => {
const user = users.find(u => u.username === username && u.password === password);
return user ? done(null, user) : done(null, false);
}
));
// Serialize only the user ID
passport.serializeUser((user, done) => {
done(null, user.id);
});
// Deserialize by fetching the full user object
passport.deserializeUser((id, done) => {
const user = users.find(u => u.id === id);
return user ? done(null, user) : done(null, false);
});
app.post('/login',
passport.authenticate('local', { failureRedirect: '/login-fail' }),
(req, res) => {
// After login, req.user is populated via deserializeUser
res.json({ message: 'logged in', user: req.user });
}
);
app.get('/profile', (req, res) => {
if (!req.user) return res.sendStatus(401);
res.json({ profile: req.user });
});
app.listen(3000, () => console.log('Listening on http://localhost:3000'));
Where to run: In a terminal with Node ≥ 14 installed. Permissions: No special rights needed; the app binds to localhost:3000. Expected check: After posting credentials to /login, the response should include req.user. Accessing /profile should return the user object; if you change deserializeUser to return false, the same request to /profile yields 401, proving the callback’s necessity.
Switching to a stateless JWT strategy
When you replace passport-local with passport-jwt, the JWT itself carries the user identity. You could omit serializeUser and deserializeUser entirely because Passport will attach the decoded payload to req.user after the JWT verification step. However, many teams keep the callbacks and simply return the decoded payload:
const JwtStrategy = require('passport-jwt').Strategy;
const ExtractJwt = require('passport-jwt').ExtractJwt;
const jwtOpts = {
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: '{{YOUR_JWT_SECRET}}'
};
passport.use(new JwtStrategy(jwtOpts, (payload, done) => {
// payload typically contains { id, username, ... }
return done(null, payload);
}));
// Optional: keep the session callbacks to reuse the same interface
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => {
// In a stateless setup you might look up the user in a DB;
// returning the payload from the JWT is also valid if you stored the id there.
const user = users.find(u => u.id === id);
return user ? done(null, user) : done(null, false);
});
With this arrangement, the JWT strategy’s verify function populates req.user (the payload), and the session callbacks are never invoked because there is no session middleware. If you later decide to add session support again, the same callbacks will work without modification.
Trade‑offs and limitations
- Performance: Storing only a minimal identifier (e.g., numeric ID) keeps the session small and lookup cheap. If you store a large user object in
serializeUser, each request incurs the cost of serializing/deserializing that blob, which can become a bottleneck under load. - Security: Never place secrets such as passwords, tokens, or password hashes in the serialized payload. The session store (whether memory, Redis, or a database) should be treated as trusted, but limiting the payload reduces exposure if the store is compromised.
- Compatibility: When multiple strategies share the same Passport instance, they all use the same
serializeUser/deserializeUserpair. If one strategy expects a different identifier format (e.g., UUID vs. integer), you must normalize the ID or use separate Passport instances. - Migration: Changing the serialization format (e.g., moving from integer IDs to UUIDs) invalidates existing sessions unless you clear the session store or provide a backward‑compatible lookup in
deserializeUser.
Practical way to verify your setup
- Log
req.userin a test route after login; confirm it contains the expected fields. - Temporarily replace
deserializeUserwith a function that returnsfalseand verify that accessing a protected route returns 401 even though a session cookie is present. - If using JWT, inspect the Authorization header; decode the token (e.g., with jwt.io) and ensure the payload matches what appears in
req.user.
These steps give you confidence that the serialization callbacks are behaving as intended without needing to modify production code.
Actionable closing
If you are debugging missing req.user values, start by checking whether express-session is mounted and whether deserializeUser returns a valid user object. When moving to a stateless JWT approach, you can safely keep the callbacks and simply return the decoded JWT payload; this preserves Passport’s familiar middleware chain while avoiding unnecessary session storage. Keep the serialized identifier minimal, verify the lookup performance, and remember that any change to the serialization format requires a session migration strategy.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.