All articles
Tutorials

Session Tokens vs JWTs: The False Dichotomy

Most auth tutorials force you to pick JWTs or sessions. Here is why that framing is wrong, and how the hybrid approach gives you the performance of JWTs with the revocation control of sessions.

Session Tokens vs JWTs: The False Dichotomy cover
9 min read

TL;DR

  • JWT-only auth benchmarks at 5,527 req/s with zero database calls, but tokens cannot be revoked mid-lifetime. A deleted account retains full access until the JWT expires, up to 7 days by default.
  • Session-only auth gives instant revocation but queries the database on every request. At 4,561 req/s that is 4,561 auth queries per second, making auth the bottleneck for every endpoint.
  • The hybrid uses a 15-minute access JWT for the fast path and a long-lived refresh token in the database. 99% of requests skip the database entirely; revocation takes effect within one 15-minute window.
  • Benchmarked at 5,494 req/s and 0.51ms average, the hybrid outperforms JWT-only because the fast path skips connection pool overhead entirely.

A user gets fired. The admin deletes their account from the database. Their JWT is still valid for the next seven days, because the token does not know the database changed. They can still authenticate, read data, and act on behalf of a now-deleted account.

This article covers both auth approaches, why each fails under realistic conditions, and the hybrid pattern that gives you the performance of JWTs with the revocation control of sessions, backed by benchmarks on real PostgreSQL workloads.

The JWT-only approach

JWT-only auth is stateless: the server signs a token on login, and every subsequent request is verified cryptographically without consulting any external store. A typical implementation looks like this:

lib/auth.ts
import jwt from "jsonwebtoken";
import type { Response } from "express";
 
// On login
const token = jwt.sign(
  { userId: user.id, role: user.role },
  process.env.JWT_SECRET!,
  { expiresIn: "7d" }
);
res.cookie("token", token, { httpOnly: true });
 
// On every request
const payload = jwt.verify(req.cookies.token, process.env.JWT_SECRET!);

No database queries on the hot path. Any server with the secret can verify the token. The check completes in under one millisecond.

The problems appear when you need to revoke access. Because the token is self-contained, the server has no mechanism to reject a valid, unexpired token without consulting external state:

Fired employee. The JWT carries a valid expiry signed by the server secret. Deleting the user's database record does not invalidate it. The user retains full access for up to seven days.

Role change. The token carries role: "admin". Downgrading the user in the database does not change what the token says until it expires and they re-authenticate.

Device revocation. A user's device is stolen. There is no mechanism to invalidate its token before expiry.

Benchmarked with PostgreSQL, 100 concurrent connections:

MetricJWT-only
Average response time0.97ms
Throughput5,527 req/sec
Database queries per second0

The performance is real. So is the revocation problem.

The usual defence is that microservices require stateless auth because services cannot share state. In practice, even Netflix-scale architectures share infrastructure: caches, message queues, logging pipelines. The session store concern is real at genuine distributed systems scale; most teams are nowhere near it.

The session-only approach

Sessions solve revocation completely by keeping authority in the database. On every request, the server fetches the session record and checks it is still valid:

lib/auth.ts
import crypto from "crypto";
import type { Response } from "express";
 
// On login
const sessionId = crypto.randomBytes(32).toString("hex");
await db.session.create({
  data: {
    id: sessionId,
    userId: user.id,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
  },
});
res.cookie("sessionId", sessionId, { httpOnly: true });
 
// On every request
const session = await db.session.findUnique({
  where: { id: req.cookies.sessionId },
  include: { user: true },
});
 
if (!session || session.expiresAt < new Date()) {
  return res.status(401).json({ error: "Invalid session" });
}

Delete the session row and the user is locked out immediately. Every revocation scenario from the JWT section is handled, because the database is always the source of truth.

The cost is a database query on every request, without exception. A dashboard that makes 50 API calls on load runs 50 auth queries before any business logic executes:

Benchmarked with PostgreSQL, 100 concurrent connections:

MetricSession-only
Average response time1.52ms
Throughput4,561 req/sec
Database queries per second4,561

At 4,561 requests per second, the database absorbs 4,561 auth queries per second. As traffic grows, this bottlenecks every endpoint regardless of how well the business logic is optimized.

Flow comparison: JWT-only skips the database on every request; session-only hits it on every request; the hybrid hits it only on token refresh

Implementing the hybrid auth pattern

The resolution is to use both mechanisms for what each is designed for. A short-lived JWT handles the fast path; a database-backed refresh token provides revocation control.

An access token is a 15-minute JWT. It carries enough data to authenticate requests without a database call on the common path.

A refresh token is a random string stored in the database with a 30-day lifetime. It is only used when the access token expires: the middleware validates the session, fetches fresh user data, and issues a new access token.

createSession writes the refresh token to the database and sends both tokens as cookies:

lib/auth.ts
import jwt from "jsonwebtoken";
import crypto from "crypto";
import type { Response } from "express";
 
interface TokenPayload {
  userId: string;
  role: string;
}
 
export async function createSession(
  userId: string,
  role: string,
  res: Response
): Promise<void> {
  const refreshToken = crypto.randomBytes(32).toString("hex");
 
  await db.session.create({
    data: {
      userId,
      refreshToken,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
    },
  });
 
  const accessToken = jwt.sign(
    { userId, role } as TokenPayload,
    process.env.JWT_SECRET!,
    { expiresIn: "15m" }
  );
 
  res.cookie("accessToken", accessToken, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 15 * 60 * 1000,
  });
  res.cookie("refreshToken", refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 30 * 24 * 60 * 60 * 1000,
  });
}

The protect middleware tries the fast path first. When the access token is valid, it passes through in under a millisecond. When it is expired or absent, the middleware falls back to the refresh token, queries the database once, and issues a new access token:

middleware/protect.ts
import jwt from "jsonwebtoken";
import type { Request, Response, NextFunction } from "express";
 
interface TokenPayload {
  userId: string;
  role: string;
}
 
interface AuthRequest extends Request {
  userId?: string;
}
 
export async function protect(
  req: AuthRequest,
  res: Response,
  next: NextFunction
): Promise<void> {
  const { accessToken, refreshToken } = req.cookies as {
    accessToken?: string;
    refreshToken?: string;
  };
 
  try {
    const payload = jwt.verify(
      accessToken!,
      process.env.JWT_SECRET!
    ) as TokenPayload;
 
    req.userId = payload.userId;
    next();
    return;
  } catch {
    // Access token expired or absent; attempt refresh
    if (!refreshToken) {
      res.status(401).json({ error: "Not authenticated" });
      return;
    }
 
    const session = await db.session.findUnique({
      where: { refreshToken },
      include: { user: true },
    });
 
    if (!session || session.expiresAt < new Date()) {
      res.status(401).json({ error: "Session expired" });
      return;
    }
 
    const newAccessToken = jwt.sign(
      { userId: session.userId, role: session.user.role } as TokenPayload,
      process.env.JWT_SECRET!,
      { expiresIn: "15m" }
    );
 
    res.cookie("accessToken", newAccessToken, {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      maxAge: 15 * 60 * 1000,
    });
    req.userId = session.userId;
    next();
  }
}

99% of requests hit the fast path. The other 1% run one database query, fetch fresh user data, and issue a new access token.

Token lifecycle: fast path skips the database; token refresh triggers one database query and issues a fresh access token

On the revocation scenarios from earlier: deleting the session row locks the user out within one 15-minute token lifetime. A role change takes effect the next time the access token refreshes. Deleting all session rows for a user logs out every device simultaneously.

Benchmarked with PostgreSQL, 100 concurrent connections:

MetricHybrid
Average response time0.51ms
Throughput5,494 req/sec
Database queries per second~0 (only on token refresh, ~1% of requests)

The hybrid outperforms JWT-only because access token verification has no database connection overhead, not even a pool checkout. Full benchmark methodology: stat-tests/RESULTS.md.

Database load comparison: JWT-only and hybrid are near-zero; session-only scales linearly with request volume

Tightening the hybrid for high-security requirements

For systems where a 15-minute revocation window is not acceptable, two options tighten it further.

Shorter access token lifetime. Reducing expiry to 5 minutes or 1 minute keeps the same 99%/1% fast-path ratio but forces more frequent refreshes. The tradeoff is higher per-user database load at scale.

Redis token blacklist. Maintain a set of revoked token IDs in Redis and check it on every request. This middleware adds the blacklist check before passing through:

middleware/protect.ts
import jwt from "jsonwebtoken";
import { redis } from "../lib/redis";
import type { Request, Response, NextFunction } from "express";
 
interface TokenPayload {
  userId: string;
  role: string;
  jti: string; // JWT ID claim, required for blacklisting
}
 
interface AuthRequest extends Request {
  userId?: string;
}
 
export async function protect(
  req: AuthRequest,
  res: Response,
  next: NextFunction
): Promise<void> {
  try {
    const payload = jwt.verify(
      req.cookies.accessToken,
      process.env.JWT_SECRET!
    ) as TokenPayload;
 
    const isRevoked = await redis.get(`blacklist:${payload.jti}`);
    if (isRevoked) {
      res.status(401).json({ error: "Token revoked" });
      return;
    }
 
    req.userId = payload.userId;
    next();
  } catch {
    // Refresh token flow (same as above)...
  }
}

Tip

Set each blacklist entry's TTL to match the remaining lifetime of the revoked token. This prevents unbounded growth while still covering the revocation window. Include a jti claim when signing access tokens so you have a stable key to write to Redis.

The Redis check adds approximately 1 millisecond per request but provides immediate revocation without waiting for token expiry.

When this breaks down

The hybrid approach is the right default for most applications, but three situations call for a different choice.

Very short TTLs already. If your access tokens already expire in under 5 minutes across the board, JWT-only is acceptable. The revocation window is small enough that the operational risk is low, and the implementation is simpler.

Under 10 requests/sec with no scaling plans. At very low traffic the database load from session-only auth is negligible. The simplicity of a single lookup per request may be worth more than the performance headroom.

Permissions that change multiple times per day. The 15-minute staleness window means a permission change takes effect at the next token refresh, not immediately. For systems where instant permission propagation is a hard requirement (financial platforms, healthcare, admin tooling), use 5-minute tokens or add a Redis blacklist keyed by jti.

The tradeoff between JWTs and sessions is real. The choice between them is not.

Share𝕏

Writer

  • Wale Bashir

    Technical content writer and full-stack engineer with experience across Web3, AI, and backend systems.

Need help with your technical content?

We help B2B SaaS teams turn complex products into clear documentation and content that developers actually use.

Book a call
JWT vs Session Tokens: Which Should You Use? | Reclear