Passport.js: Session-Based Auth vs. JWT — A Decision Guide with Implementation
Passport.js leaves the key architectural choice to you: server-side sessions with passport-local, or stateless tokens with passport-jwt. Compare the real trade-offs — revocation, scaling, CSRF, XSS — then implement and validate session auth concretely.
24 May 2026, 18:03 UTC

The decision you actually have to make
Passport is middleware, not an authentication system. It gives you a strategy interface (passport.use()) and hooks like req.isAuthenticated(), but you still own user lookup, password hashing, storage, and — most importantly — the choice of how authenticated state survives between requests. For a typical Express app that choice comes down to two supported patterns:
- Session-based:
passport-local+express-session. The server keeps state; the browser holds only a session cookie. - Stateless:
passport-jwt. The client holds a signed token and sends it on every request; the server stores nothing.
This guide compares them on the constraints that actually matter, then shows a concrete session implementation and how to validate it.
Compare the two options
| Concern | Session (passport-local + express-session) | JWT (passport-jwt) |
|---|---|---|
| Server state | Session store required (Redis in production; the default MemoryStore is dev-only) | None — signature verification only |
| Horizontal scaling | Needs a shared session store across instances | Works out of the box; any instance can verify the token |
| Revocation / logout | Immediate: destroy the session | Hard: token is valid until exp; needs short expiry plus refresh tokens or a denylist |
| CSRF exposure | Yes — cookies are sent automatically; need SameSite cookies and/or CSRF tokens | No classic CSRF if the token travels in an Authorization header |
| XSS exposure | Cookie can be HttpOnly, so JS can't read it | Token in localStorage is readable by any injected script |
| Best fit | Server-rendered apps, same-site SPAs, admin panels | APIs consumed by mobile apps or third parties, microservices |
How to read the trade-offs
Revocation is the sharpest difference. With sessions, "ban this user now" is one store delete. With JWTs, a stolen or stale token works until it expires. The standard mitigation is short-lived access tokens (minutes) plus refresh tokens stored server-side — at which point you've reintroduced server state and much of the complexity you were avoiding.
Scaling cuts the other way. Sessions behind multiple app instances require a shared store such as Redis (connect-redis). That's one extra infrastructure dependency, but it's a solved problem. JWTs verify anywhere with just the signing key, which is why they dominate service-to-service and mobile API designs.
Browser threat models differ. Cookie-based sessions are exposed to cross-site request forgery because browsers attach cookies automatically; mitigate with SameSite=Lax/Strict and CSRF tokens on mutating routes. Header-based JWTs dodge CSRF entirely, but if you store the token in localStorage, any XSS payload can exfiltrate it. Putting the JWT in an HttpOnly cookie fixes XSS theft but brings CSRF back — there is no free lunch.
Rule of thumb: if your client is a first-party browser app on the same site, sessions are simpler and safer. If you're issuing credentials to parties you don't control, or scaling many stateless services, JWTs earn their complexity.
Concrete implementation: session-based login
Assumptions: Node 18+, Express 4, passport 0.6+, passport-local, express-session. Run this in your app entry point; no special permissions needed beyond your normal Node process.
const express = require('express');
const session = require('express-session');
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const bcrypt = require('bcrypt');
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(session({
secret: process.env.SESSION_SECRET, // load from env, never hardcode
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: 'lax', secure: false } // secure: true behind HTTPS
// store: new RedisStore({ client }) — required for production
}));
app.use(passport.initialize());
app.use(passport.session());
passport.use(new LocalStrategy(async (username, password, done) => {
try {
const user = await findUserByName(username); // your DB lookup
// Same generic message either way: avoids user enumeration
if (!user) return done(null, false, { message: 'Invalid credentials' });
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) return done(null, false, { message: 'Invalid credentials' });
return done(null, user);
} catch (err) { return done(err); }
}));
// Store only the id in the session; reload the user per request
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser(async (id, done) => {
try { done(null, await findUserById(id)); } catch (err) { done(err); }
});
app.post('/login',
passport.authenticate('local', { failureRedirect: '/login?error=1' }),
(req, res) => res.redirect('/dashboard')
);
// Passport 0.6+: req.logout is asynchronous and needs a callback
app.post('/logout', (req, res, next) => {
req.logout(err => err ? next(err) : res.redirect('/'));
});
app.get('/dashboard', (req, res) => {
if (!req.isAuthenticated()) return res.status(401).send('login required');
res.send(`hello ${req.user.username}`);
});Two details worth noting. First, passwords are hashed with bcrypt (argon2 is equally acceptable) and the failure message is identical whether the username or the password was wrong — distinct messages let attackers enumerate valid accounts. Second, serializeUser stores only the user id, keeping the session payload small; deserializeUser runs on every authenticated request, so make that lookup cheap or cache it.
Validate that it actually works
Don't trust the code by reading it — exercise it:
- Happy path: POST valid credentials to
/loginand confirm a302to/dashboardplus aSet-Cookieheader withHttpOnlyandSameSiteattributes. - Protected route: GET
/dashboardwith the session cookie → expect 200 and the username. Without the cookie → expect 401. - Bad credentials: POST a wrong password → expect the failure redirect and no session cookie.
- Session contents: inspect the store (e.g.,
KEYS */GETin Redis) and confirm only the user id and cookie metadata are stored, not the full user object or password hash. - Logout: POST
/logout, then reuse the old cookie against/dashboard→ expect 401. - Automate it: a supertest integration test can assert all of the above in CI — post credentials, capture the cookie from the response, replay it against the protected route, and assert 200 with it and 401 without it.
Limitations and when to revisit
This sketch uses the default in-memory session store, which loses all sessions on restart and breaks behind more than one process — swap in connect-redis (or another persistent store) before deploying. CSRF protection is not shown; add token-based CSRF middleware for any state-changing route once you serve a real frontend. And if you later need to authenticate third-party API clients, that's the point to add passport-jwt alongside the session strategy rather than replacing it — Passport strategies coexist cleanly. Finally, req.logout/req.login changed to callback-based async in Passport 0.6; check the changelog before upgrading an older codebase.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.