JWT vs session cookies: choosing authentication for web apps
JWTs and server-side sessions solve different problems. A practical comparison of storage, revocation, CSRF and XSS trade-offs, refresh tokens and httpOnly cookies.
Muhammad Faizan
Founder & Lead Engineer
Almost every new web app hits the same fork early: issue a JSON Web Token after login, or create a server-side session and hand the browser a cookie. Tutorials tend to present JWTs as the modern choice and sessions as legacy. That framing causes real problems: tokens in localStorage that any XSS bug can steal, logout buttons that do not actually log anyone out, and refresh-token schemes that quietly turn a stateless design back into a stateful one.
The useful question is not JWT or cookies. Those are not even opposites: a JWT is a token format, a cookie is a transport. The real decisions are where the credential lives in the browser, where the source of truth for the session lives on the server, and how quickly you need to revoke access. Answer those and the choice usually makes itself.
What each approach actually is
A server-side session stores session state in your database or Redis, keyed by a random, unguessable ID. The browser receives only that ID in a cookie. On each request you look the ID up, and if the row is gone, the user is logged out. Revocation is a DELETE.
A JWT carries claims (user ID, roles, expiry) signed by the server. Any service holding the verification key can check it without a database round trip. That is the genuine advantage: verification is local and cheap, which matters when many services or edge functions need to authenticate the same request. The cost is that a valid token stays valid until it expires, no matter what happens on the server.
- Sessions give you instant revocation, small cookies and a single source of truth, at the cost of a lookup per request (usually a fast Redis or primary-key read).
- JWTs give you stateless verification across services, at the cost of weak revocation, larger payloads and more ways to misconfigure signing.
- For a typical web app with one backend and a browser frontend, sessions are simpler and safer. JWTs earn their place in multi-service systems, third-party API access and mobile clients.
Where to store the credential: XSS and CSRF
Whatever the token format, the browser has to keep it somewhere. The two realistic options are JavaScript-accessible storage (localStorage, sessionStorage, memory) and an httpOnly cookie. They fail in different ways.
Tokens readable by JavaScript are exposed to XSS. One injected script, whether from a vulnerable dependency, a markdown renderer or a compromised analytics tag, can read the token and exfiltrate it. The attacker then uses it from their own machine until it expires. An httpOnly cookie cannot be read by script at all. XSS is still serious, since the attacker can make requests from the victim's browser, but they cannot walk away with a long-lived credential.
Cookies, in turn, are sent automatically, which creates CSRF risk: another site can trigger a request that carries your user's cookie. Modern browsers mitigate most of this with SameSite. SameSite=Lax (the default in Chromium-based browsers when unset) blocks cookies on cross-site POST requests while keeping top-level navigation working. For state-changing endpoints, add a second check: verify the Origin header or require a CSRF token.
import { randomBytes, createHash } from "node:crypto";
import type { Response } from "express";
import { db } from "../db";
const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7; // 7 days
export async function startSession(res: Response, userId: string) {
// 256 bits of randomness; only a hash is stored server-side
const token = randomBytes(32).toString("base64url");
const tokenHash = createHash("sha256").update(token).digest("hex");
await db.query(
`INSERT INTO sessions (token_hash, user_id, expires_at)
VALUES ($1, $2, now() + interval '7 days')`,
[tokenHash, userId],
);
res.cookie("__Host-session", token, {
httpOnly: true, // not readable from JavaScript
secure: true, // HTTPS only (required by the __Host- prefix)
sameSite: "lax", // not sent on cross-site POSTs
path: "/", // required by the __Host- prefix
maxAge: SESSION_TTL_MS,
});
}
export async function endSession(token: string) {
const tokenHash = createHash("sha256").update(token).digest("hex");
await db.query("DELETE FROM sessions WHERE token_hash = $1", [tokenHash]);
}Two details worth copying. The __Host- prefix makes the browser reject the cookie unless it is Secure, has Path=/ and has no Domain attribute, so a sibling subdomain cannot overwrite it. And storing only a hash of the token means a leaked database dump does not hand out live sessions.
The revocation problem with JWTs
Users change passwords, admins disable accounts, laptops get stolen. With sessions you delete a row. With a self-contained JWT there is nothing to delete: the signature is still valid. The standard mitigation is to make access tokens short-lived, typically minutes rather than hours, and pair them with a refresh token that is tracked server-side.
Notice what happened: to get revocation back, you added server state. That is fine and usually the right design, but it means the stateless argument for JWTs is weaker than it looks. If you need a lookup for refresh anyway, ask whether the short-lived JWT is buying you anything in your architecture. In a single monolith it usually is not.
Refresh tokens done properly
If you do use JWT access tokens, the refresh flow is where most of the security lives. Store refresh tokens in an httpOnly, Secure cookie scoped to the refresh endpoint's path. Store a hash of each one server-side with a family ID. On every refresh, issue a new refresh token and invalidate the old one. This is rotation.
Rotation enables reuse detection: if an already-used refresh token shows up again, either the legitimate client or an attacker has a copy it should not. You cannot tell which, so revoke the whole family and force a fresh login.
import { jwtVerify, SignJWT } from "jose";
import { db, hash, newToken } from "./store";
const key = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, key, {
algorithms: ["HS256"], // pin the algorithm
issuer: "https://api.example.com",
audience: "web",
});
return payload;
}
export async function rotateRefreshToken(presented: string) {
const row = await db.refreshTokens.findByHash(hash(presented));
if (!row) throw new Error("invalid refresh token");
if (row.usedAt) {
// Reuse of a rotated token: assume theft, kill the whole family
await db.refreshTokens.revokeFamily(row.familyId);
throw new Error("refresh token reuse detected");
}
await db.refreshTokens.markUsed(row.id);
const next = newToken();
await db.refreshTokens.insert({ hash: hash(next), familyId: row.familyId, userId: row.userId });
const access = await new SignJWT({ sub: row.userId })
.setProtectedHeader({ alg: "HS256" })
.setIssuer("https://api.example.com")
.setAudience("web")
.setExpirationTime("10m")
.sign(key);
return { access, refresh: next };
}A decision guide
- Browser app talking to its own backend: server-side sessions in an
httpOnly,SameSite=Laxcookie. Simplest, and revocation is instant. - Several backend services or edge middleware need to authenticate requests: short-lived JWT access tokens, rotated refresh tokens tracked server-side, both delivered in cookies for browsers.
- Mobile apps and third-party API clients: tokens in the
Authorizationheader, stored in the platform keychain or keystore, with the same short-lived access plus rotated refresh pattern. - Using an identity provider (OIDC): let it issue tokens, but keep a server-side session for your web app (the backend-for-frontend pattern) rather than shipping provider tokens to the browser.
Key takeaways
- JWT is a format and a cookie is a transport; decide on storage, source of truth and revocation separately.
- Keep credentials in
httpOnly,Securecookies so XSS cannot steal them, and useSameSiteplus Origin or CSRF-token checks for state-changing requests. - Plain sessions are the right default for a single web backend.
- If you use JWTs, keep access tokens short-lived, pin algorithms, and rotate refresh tokens with reuse detection.
Designing auth for a new product or untangling an existing setup? Our backend team builds authentication, authorization and session management that holds up in production. Get in touch.
Frequently asked questions
Is it safe to store a JWT in localStorage?
It works, but any XSS bug can read the token and send it anywhere. An httpOnly cookie keeps the token out of JavaScript, which removes the most common way tokens are stolen.
How do I log a user out when using JWTs?
You cannot invalidate a signed token directly. Keep access tokens short-lived, revoke the refresh token on the server, and use a denylist or token version for emergencies.
Do I still need CSRF protection with SameSite cookies?
SameSite=Lax blocks most cross-site form posts, but add a CSRF token or an Origin check for state-changing requests if you support older browsers or have subdomains you do not fully control.
Need help with backend & apis?
Secure, scalable backends and APIs with Node.js, Nest.js and Python, built for real traffic.