Sessions vs JWTs: Choosing the Right Auth Architecture
Authentication architecture is one of those decisions that seems straightforward until you're three months into a project and realize you picked the wrong pattern for your use case. The internet is full of "just use JWTs" advice from people who've never had to implement logout that actually works, and "just use sessions" advice from people who've never scaled past a single server.
The reality is that both approaches have legitimate strengths. The question isn't which one is "better" — it's which set of trade-offs matches your application. This post breaks down both architectures honestly, covers the access/refresh token pattern that most tutorials botch, and addresses CSRF protection that cookie-based auth requires.
Stateful Authentication: Server-Side Sessions
The traditional model. When a user logs in, the server creates a session record, stores it somewhere (memory, database, Redis), and sends the client a session ID in a cookie. Every subsequent request includes that cookie, and the server looks up the session to identify the user.
import session from 'express-session';
import RedisStore from 'connect-redis';
import { createClient } from 'redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: true, // HTTPS only
httpOnly: true, // No JavaScript access
sameSite: 'strict', // CSRF protection layer
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
app.post('/login', async (req, res) => {
const user = await verifyCredentials(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
req.session.userId = user.id;
req.session.role = user.role;
res.json({ message: 'Logged in' });
});What Sessions Get Right
Instant revocation. This is the killer feature. To log a user out — really out, not "we deleted the cookie but the token is still valid for 14 minutes" — you delete the session from the store. The very next request fails authentication. For applications where you need to immediately revoke access after a password change, account compromise, or permission update, sessions deliver this out of the box.
Small cookie size. The session cookie contains just an opaque ID — typically 32-64 bytes. Compare that to a JWT that carries a full payload and signature, which can easily hit 800+ bytes. This matters when you multiply it by every request.
Server-side control. You can see all active sessions for a user, enforce concurrent session limits ("logged in from 3 devices, log out the oldest"), and collect session analytics. This visibility is something stateless architectures trade away.
What Sessions Get Wrong
Scaling requires shared state. If you run multiple server instances behind a load balancer, they all need access to the same session store. In-memory sessions don't work at all in this setup. Redis is the standard solution, but it's an additional infrastructure component to provision, monitor, and maintain. For a single-server deployment, this doesn't matter. For a horizontally scaled architecture, it's a real cost.
Latency on every request. Every authenticated request requires a round trip to the session store. With Redis, that's typically 1-3ms — negligible for most applications, but it's there. For latency-sensitive APIs, this per-request overhead is worth knowing about.
Stateless Authentication: JWTs
The JWT approach flips the model. Instead of storing session state on the server, the server encodes the user's identity and claims into a signed token. The client stores the token and sends it with every request. The server verifies the signature and reads the claims without hitting any database.
import jwt from 'jsonwebtoken';
app.post('/login', async (req, res) => {
const user = await verifyCredentials(req.body.email, req.body.password);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
process.env.ACCESS_SECRET,
{ algorithm: 'HS256', expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ sub: user.id },
process.env.REFRESH_SECRET,
{ algorithm: 'HS256', expiresIn: '7d' }
);
// Store refresh token server-side for rotation tracking
await db.refreshTokens.create({
userId: user.id,
token: hashToken(refreshToken),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
// Refresh token in HttpOnly cookie, access token in response body
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/api/auth/refresh', // Only sent to refresh endpoint
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken });
});What JWTs Get Right
No server-side session store needed. The token is self-contained. Any server with the signing key can verify it. This makes horizontal scaling simpler — no Redis cluster, no shared state, no sticky sessions. For microservice architectures where multiple services need to verify identity, JWTs are particularly elegant.
Cross-domain and mobile friendly. JWTs work naturally with mobile apps, SPAs on different domains, and third-party API consumers. Cookie-based sessions are bound by browser cookie rules (same-site policies, third-party cookie restrictions), which creates friction for cross-origin scenarios.
What JWTs Get Wrong
Revocation is hard by design. A JWT is valid until it expires. You can't "invalidate" it without server-side state — a token blocklist, a version counter in the database, something. The whole point of JWTs was to avoid server-side state, so adding a blocklist partially defeats the purpose. This is the fundamental tension.
Token size. A minimal JWT with a user ID and role is around 200-300 bytes. Add a few claims, use RS256 instead of HS256, and you're north of 800 bytes. That's on every request, in the Authorization header or a cookie. It adds up.
The Access/Refresh Pattern: Where Most Implementations Break
The standard pattern is a short-lived access token (15 minutes) paired with a longer-lived refresh token (7 days). The access token goes with every API request. When it expires, the client uses the refresh token to get a new access token without forcing the user to log in again.
Simple enough in theory. In practice, there are three mistakes that show up constantly.
Mistake #1: Not Rotating Refresh Tokens
// ❌ Reusing the same refresh token until it expires
app.post('/api/auth/refresh', async (req, res) => {
const { refreshToken } = req.cookies;
const payload = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
const newAccessToken = jwt.sign({ sub: payload.sub }, process.env.ACCESS_SECRET, { expiresIn: '15m' });
res.json({ accessToken: newAccessToken });
// Same refresh token stays valid — if stolen, attacker has it for 7 days
});
// ✅ Issue a new refresh token every time, invalidate the old one
app.post('/api/auth/refresh', async (req, res) => {
const { refreshToken } = req.cookies;
const payload = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
// Check if this refresh token is still valid in the database
const stored = await db.refreshTokens.findOne({
where: { token: hashToken(refreshToken), revoked: false }
});
if (!stored) {
// Token reuse detected — possible theft. Revoke entire family.
await db.refreshTokens.update({ revoked: true }, { where: { userId: payload.sub } });
return res.status(401).json({ error: 'Token reuse detected. Please log in again.' });
}
// Rotate: revoke old, issue new
await db.refreshTokens.update({ revoked: true }, { where: { id: stored.id } });
const newRefreshToken = jwt.sign({ sub: payload.sub }, process.env.REFRESH_SECRET, { expiresIn: '7d' });
await db.refreshTokens.create({
userId: payload.sub,
token: hashToken(newRefreshToken),
familyId: stored.familyId, // Track token lineage
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true, secure: true, sameSite: 'strict',
path: '/api/auth/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000
});
res.json({ accessToken: newAccessToken });
});The
familyIdMistake #2: Not Invalidating on Password Change
When a user changes their password, every existing refresh token should be revoked. This seems obvious, but the number of implementations that skip this step is surprising. If an attacker has a stolen refresh token and the user changes their password to "lock them out," the attacker can keep refreshing indefinitely if you don't purge old tokens.
app.post('/api/auth/change-password', async (req, res) => {
await updatePassword(req.user.id, req.body.newPassword);
// Revoke ALL refresh tokens for this user
await db.refreshTokens.update(
{ revoked: true },
{ where: { userId: req.user.id } }
);
res.json({ message: 'Password changed. Please log in again on all devices.' });
});Mistake #3: Storing Refresh Tokens in localStorage
The access token being in memory (a JavaScript variable) is reasonable — it's short-lived and dies with the tab. The refresh token in localStorage is not. If your app has any XSS vulnerability, an attacker can exfiltrate the refresh token and use it from their own machine for its full lifetime (potentially days or weeks). The JWT security post covers this in detail. Refresh tokens belong in HttpOnly cookies.
CSRF Protection for Cookie-Based Auth
If you're using cookies (whether session IDs or refresh tokens), you need CSRF protection. A CSRF attack tricks a logged-in user's browser into making a request to your API — the browser automatically attaches the cookie, so the request appears authenticated.
import csrf from 'csurf';
// For traditional form submissions
const csrfProtection = csrf({ cookie: { httpOnly: true, sameSite: 'strict' } });
app.use(csrfProtection);
// For SPAs: the synchronizer token pattern
app.get('/api/auth/csrf-token', (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
// The SPA includes this token in a custom header with every request
// Attackers can't read the token cross-origin, so they can't forge the headersameSite: 'strict'sameSite: 'lax'When to Use Which
| Factor | Sessions | JWTs | |--------|----------|------| | Single server app | Best fit | Works but unnecessary | | Microservices | Requires shared store | Natural fit | | Need instant logout | Built-in | Requires blocklist | | Mobile app clients | Awkward (cookies) | Natural fit | | Cross-domain API | Difficult | Easy | | Token size | Tiny (32 bytes) | Large (300-800+ bytes) | | Server storage | Required (Redis) | Optional (for refresh) |
The honest answer most teams land on: a hybrid. Sessions or HttpOnly cookie JWTs for the web app (where you need CSRF protection anyway), and bearer token JWTs for mobile and API clients. The refresh endpoint uses server-side storage regardless — even in the "stateless" JWT model, you need a database to do token rotation properly.
The Bottom Line
There's no universally correct auth architecture. But there are universally incorrect patterns: refresh tokens that don't rotate, password changes that don't invalidate sessions, and the absence of CSRF protection on cookie-based auth. Whatever architecture you choose, get those three things right and you're ahead of most implementations in production today.
Discussion
0 comments
Share your thoughts
No comments yet. Be the first to share your thoughts!