Back to articles
web servers
11 min readMarch 22, 2026

Sessions vs JWTs: Choosing the Right Auth Architecture

Stateful or stateless? Cookies or tokens? The auth architecture debate has real trade-offs most tutorials gloss over. Here's a practical comparison — including the refresh token mistakes that lead to silent account takeovers.

Sessions vs JWTs: Choosing the Right Auth Architecture

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.

javascript
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.

javascript
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

javascript
// ❌ 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

familyId
is the key detail. All refresh tokens descended from a single login share a family ID. If a revoked token is reused (meaning the attacker is using a stolen token while the real user already refreshed), you invalidate the entire family and force a re-login. This is called refresh token rotation with reuse detection, and it's the only pattern that actually limits the damage window of a stolen refresh token.

Mistake #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.

javascript
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.

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.

javascript
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 header

sameSite: 'strict'
on your cookies is the strongest defense and handles most CSRF scenarios. But it breaks legitimate cross-site navigation (clicking a link to your site from an email won't carry the cookie).
sameSite: 'lax'
is the practical middle ground — it blocks cross-site POST requests while allowing normal navigation.

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!