Keeping Passport Sessions Tiny: serializeUser, middleware order, and the per‑request lookup trade‑off
Passport.js sessions can balloon if you store the whole user profile. This blog explains why keeping the session tiny—by serializing only an ID—matters, how to order middleware correctly, and the trade‑off of a per‑request database lookup.
14 Oct 2026, 16:01 UTC

Problem: A bloated session can break your app
When you add Passport.js to an Express app you often see a line like req.session.passport.user holding a full user profile. That looks convenient, but it can grow the session cookie or the store to dozens of kilobytes and make every request slower. The real issue is that the session payload should stay small while the app still has a fresh copy of the user on each request.
Why the session size matters
Express‑session stores the session in a cookie (signed, optionally encrypted) or in a server‑side store. If the session contains a large user object:
- Cookies exceed the 4 KB limit on many browsers.
- Server memory or Redis storage grows linearly with the number of users.
- Every request downloads the whole profile, even if only the user ID is needed.
Passport’s design encourages you to keep the session lean by serializing only a stable identifier.
Passport’s session contract
Passport exposes two callbacks that you must implement when you want session support:
- serializeUser – called once when a user logs in. It receives the full user object and must return a value that will be stored in
req.session.passport.user. The common pattern isreturn user.id;. - deserializeUser – called on every subsequent request that contains a session. It receives the value stored by
serializeUserand must load the user object (usually from a database) and attach it toreq.user.
Because deserializeUser runs on every request, the session payload stays minimal while the application still has a fresh user instance.
Middleware ordering: the secret sauce
Passport relies on express-session to provide the session store. The correct order of middleware matters; otherwise passport.session() never sees the session data and authentication silently fails.
// app.js
const express = require('express');
const session = require('express-session');
const passport = require('passport');
const app = express();
// 1️⃣ Initialize Passport – it sets up req.login, req.logout, etc.
app.use(passport.initialize());
// 2️⃣ Session middleware – must run before passport.session()
app.use(session({
secret: 'super‑secret',
resave: false,
saveUninitialized: false
}));
// 3️⃣ Passport session support – restores req.user from the session
app.use(passport.session());
// ... routes ...
If you swap the order of passport.initialize() and session(), passport.session() will not find req.session and authentication will break. If you place passport.session() before passport.initialize(), req.user will never be set. The ordering is subtle but critical.
Concrete example: a minimal login flow
Below is a tiny Express app that uses passport-local for username/password authentication and demonstrates the session contract. Replace the findUserById and verifyPassword stubs with your own data layer.
const express = require('express');
const session = require('express-session');
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
// Dummy data layer
const users = [{ id: 1, username: 'alice', password: 'secret' }];
const findUserById = id => users.find(u => u.id === id);
const verifyPassword = (user, pass) => user.password === pass;
passport.use(new LocalStrategy((username, password, done) => {
const user = users.find(u => u.username === username);
if (!user) return done(null, false, { message: 'User not found' });
if (!verifyPassword(user, password)) return done(null, false, { message: 'Wrong password' });
return done(null, user);
}));
passport.serializeUser((user, done) => {
// Store only the user id – keep the session tiny
done(null, user.id);
});
passport.deserializeUser((id, done) => {
// Load a fresh user instance on every request
const user = findUserById(id);
if (user) done(null, user);
else done(new Error('User not found')); // session stale
});
const app = express();
app.use(express.urlencoded({ extended: true }));
app.use(passport.initialize());
app.use(session({ secret: 'shhhh', resave: false, saveUninitialized: false }));
app.use(passport.session());
app.post('/login', passport.authenticate('local', {
successRedirect: '/profile',
failureRedirect: '/login'
}));
app.get('/profile', (req, res) => {
if (!req.isAuthenticated()) return res.redirect('/login');
res.send(`Hello, ${req.user.username}!`);
});
app.listen(3000, () => console.log('Server running on :3000'));
Running this app shows that req.session.passport.user contains only the numeric ID. The deserializeUser callback pulls the full profile from the array on each request.
Trade‑off: per‑request lookup vs. session size
The main decision is whether to keep the session tiny and pay for a database lookup on every request, or to cache the whole user object in the session and avoid that lookup. Each approach has pros and cons:
| Approach | Pros | Cons |
|---|---|---|
| Serialize only ID | Small session, fresh data, easy revocation | DB hit per request, higher latency |
| Serialize whole user | No DB hit after login, instant profile access | Stale data, larger session, harder to revoke |
In most production apps the first pattern is preferred. If you need to reduce DB load, add a caching layer in deserializeUser (e.g., Redis or in‑memory LRU). Remember that Passport 0.6+ regenerates the session on login, so any pre‑login state (shopping cart, OAuth CSRF state) must be preserved manually.
Practical checks
- Log the value of
req.session.passport.userafter login to confirm it’s just an ID. - Enable
express-session’sstoreoption and inspect the underlying store (Redis, Mongo, etc.) to ensure it’s not bloated. - Measure
deserializeUsercall count per request using a counter middleware. - Verify that your session middleware is
resave: falseandsaveUninitialized: falseto avoid storing empty sessions.
Conclusion
Passport’s design is intentionally lightweight: it delegates storage, hashing, and route guarding to you. By serializing only a stable identifier, ordering middleware correctly, and accepting the per‑request lookup, you keep sessions small, revocable, and secure. If you need a faster profile lookup, add a smart cache in deserializeUser; otherwise, stick to the minimal pattern and let the database enforce freshness.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.