Almost every web app needs a login, and almost every developer building one hits the same fork in the road: do you use a classic server-side session, or a JSON Web Token (JWT)?
The internet is full of strong opinions on this. Some tutorials treat JWTs as the modern default and sessions as legacy. Others say JWTs should never be used for login at all. The truth is less dramatic: both are fine when they're used for the job they suit, and both go wrong in predictable ways.
This guide explains how each one works, where each one bites, and gives you working code for doing either properly.
The short answer
For a typical web app where your own frontend talks to your own backend (a SaaS, a customer portal, an admin dashboard, an online shop), use server-side sessions in a secure cookie. They are simpler, you can log someone out instantly, and there's less to get wrong.
Reach for JWTs when a token needs to be checked by a service that can't (or shouldn't) look it up in your database: service-to-service calls, APIs that accept tokens from an identity provider like Auth0, Microsoft Entra ID or Google, or a short-lived access token in a larger OAuth setup.
The rest of this post is the why.
How a session works
- The user signs in with their password (and ideally MFA).
- The server creates a random, unguessable session ID and stores a record of it, usually in a
sessionstable or a key-value store, along with the user ID and timestamps. - The server sends the ID to the browser in a cookie.
- On every request, the browser sends the cookie back. The server looks up the ID, finds the user, and carries on.
The session ID itself means nothing. It's a claim ticket: all the real information lives on the server. To log someone out, you delete the record and the ticket is worthless.
How a JWT works
A JWT (RFC 7519) is a small JSON document with a cryptographic signature. It carries claims such as who the user is (sub), who issued the token (iss), who it's for (aud) and when it expires (exp).
- The user signs in.
- The server builds a JWT with those claims and signs it with a secret or private key.
- The client sends the token with each request, often in an
Authorization: Bearerheader. - The server checks the signature and the claims. If they're valid, it trusts the token without looking anything up.
That last point is the whole appeal, and also the whole problem.
Side by side
| Server-side session | JWT | |
|---|---|---|
| What the client holds | A random ID with no meaning | A signed token containing claims |
| Check on each request | Database or cache lookup | Signature and claim checks (no lookup) |
| Log out one device instantly | Delete the row | Not possible on its own; needs a denylist or very short expiry |
| Change a user's role | Takes effect on the next request | Takes effect when the old token expires |
| Size per request | A few dozen bytes | Often several hundred bytes or more |
| Best fit | Your app talking to your own backend | Service-to-service, third-party identity, short-lived API access |
The revocation problem
Because a JWT is trusted without a lookup, the server can't take it back. If you fire a staff member, a user's laptop is stolen, or someone resets their password because they think they've been phished, any JWT they already hold stays valid until its exp time.
OWASP's JWT cheat sheet describes the two usual fixes: keep expiry times short, and keep a denylist of revoked tokens (keyed on the token's jti and iss, not the raw token). Both are reasonable. But notice what a denylist is: a database lookup on every request. At that point you've rebuilt a session store, with extra complexity on top.
The common pattern of "15-minute access token plus a long-lived refresh token" has the same shape. The refresh token has to be stored server-side so you can revoke it, which makes it a session in everything but name.
Myths worth dropping
"JWTs are more secure." They aren't, inherently. A session ID with 256 bits of randomness can't be guessed or forged. A JWT is only as safe as your signing key and your verification code.
"Sessions don't scale." A session lookup is a single indexed read by primary key. A small product will hit dozens of other limits long before that one, and a cache or a managed key-value store handles enormous volumes if you ever get there.
"JWTs are stateless." Only until you need logout, role changes or "sign out of all devices". Most real apps need all three.
"Storing the JWT in localStorage is fine." OWASP's Session Management Cheat Sheet says not to put session IDs, JWTs or refresh tokens in localStorage or sessionStorage, because any script on the page can read them, so a single cross-site scripting (XSS) bug leaks every token. An HttpOnly cookie can't be read by JavaScript at all.
Doing sessions properly
OWASP's guidance boils down to a handful of rules:
- Generate IDs with a cryptographically secure random generator, at least 64 bits of entropy (128 or more if you're rolling your own).
- Send the cookie with
Secure,HttpOnlyand an explicitSameSite, and use the__Host-prefix so it's locked to your exact domain over HTTPS. - Issue a new session ID at login and whenever privileges change, and throw the old one away. This blocks session fixation.
- Enforce an idle timeout and an absolute timeout on the server, not just in the cookie.
Here's what that looks like in TypeScript using the Web Crypto API, which works the same in Cloudflare Workers, Deno, Bun and Node 20+. The database calls use Cloudflare D1's API, but any SQL client looks much the same.
// 32 random bytes = 256 bits, encoded so it's safe in a cookie
function newSessionToken(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// Store only a hash of the token, so a leaked database
// backup can't be replayed as live sessions
async function sha256Hex(text: string): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(text),
);
return [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export async function createSession(db: D1Database, userId: string) {
const token = newSessionToken();
const now = Date.now();
await db
.prepare(
"INSERT INTO sessions (id_hash, user_id, created_at, last_seen_at) VALUES (?, ?, ?, ?)",
)
.bind(await sha256Hex(token), userId, now, now)
.run();
const cookie = [
`__Host-session=${token}`,
"Path=/",
"Secure",
"HttpOnly",
"SameSite=Lax",
`Max-Age=${60 * 60 * 24 * 30}`,
].join("; ");
return { token, cookie }; // send `cookie` in a Set-Cookie header
}
Checking it on each request, with both timeouts enforced server-side:
const IDLE_MS = 7 * 24 * 60 * 60 * 1000; // signed out after 7 days idle
const ABSOLUTE_MS = 30 * 24 * 60 * 60 * 1000; // and after 30 days regardless
export async function getUserId(db: D1Database, token: string) {
const idHash = await sha256Hex(token);
const row = await db
.prepare("SELECT user_id, created_at, last_seen_at FROM sessions WHERE id_hash = ?")
.bind(idHash)
.first<{ user_id: string; created_at: number; last_seen_at: number }>();
if (!row) return null;
const now = Date.now();
if (now - row.last_seen_at > IDLE_MS || now - row.created_at > ABSOLUTE_MS) {
await db.prepare("DELETE FROM sessions WHERE id_hash = ?").bind(idHash).run();
return null;
}
await db
.prepare("UPDATE sessions SET last_seen_at = ? WHERE id_hash = ?")
.bind(now, idHash)
.run();
return row.user_id;
}
Logging out is DELETE FROM sessions WHERE id_hash = ?, and "sign out everywhere" (after a password reset, say) is DELETE FROM sessions WHERE user_id = ?. Both take effect on the very next request.
Pick timeouts to match the risk. The values above suit a low-risk customer app with "remember me" behaviour. OWASP gives much shorter examples for sensitive systems: idle timeouts of 15–30 minutes for low-risk office applications, a few minutes for high-value ones, and an absolute limit of 4–8 hours for a full-day office app.
Two extra notes. SameSite=Lax blocks most cross-site request forgery, but OWASP treats it as a backup, not a replacement, so forms that change data should still check a CSRF token or the Origin header. And if you only update last_seen_at once every few minutes, you cut the writes without changing the behaviour much.
When JWTs are the right tool
JWTs shine when the thing checking the token can't share your database:
- Service-to-service calls, where one backend proves who it is to another.
- APIs that accept tokens from an identity provider (Auth0, Entra ID, Google, Cognito). You verify their signature against their published keys and never store anything.
- Short-lived access tokens in an OAuth setup, backed by a revocable refresh token.
If you do use them, verify them properly. The IETF's JWT Best Current Practices (RFC 8725) says libraries must let you fix the allowed algorithms, that each key should be used with exactly one algorithm, and that you must reject tokens with the wrong issuer or audience. Using the widely used jose library:
import { SignJWT, jwtVerify } from "jose";
// At least 32 random bytes, kept in a secret store, never in the repo
const key = new TextEncoder().encode(env.JWT_SECRET);
export async function issueAccessToken(userId: string) {
return new SignJWT({ scope: "orders:read" })
.setProtectedHeader({ alg: "HS256" })
.setSubject(userId)
.setIssuer("https://api.example.com.au")
.setAudience("https://app.example.com.au")
.setIssuedAt()
.setExpirationTime("15m") // short, because you can't take it back
.sign(key);
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, key, {
algorithms: ["HS256"], // never accept whatever the token header asks for
issuer: "https://api.example.com.au",
audience: "https://app.example.com.au",
});
return payload; // `exp` and `nbf` are checked for you
}
And three things never to do with a JWT:
- Don't put secrets or personal details in it. A signed JWT is encoded, not encrypted. Anyone holding it can read every claim.
- Don't decode without verifying. Many libraries have a
decode()helper that skips the signature check. It's for debugging, not authorisation. - Don't make it long-lived unless you also have a way to revoke it.
A quick decision checklist
- Is it your frontend talking to your own backend? Sessions.
- Do you need instant logout, role changes or "sign out of all devices"? Sessions (or JWTs plus a denylist, which is a session store anyway).
- Is a separate service, or a third-party identity provider, issuing or checking the token? JWTs, short-lived, with fixed algorithms and issuer and audience checks.
- Is a mobile app calling your API? Either works. A random opaque token stored in the platform's secure keychain is often simpler than a JWT.
- Whatever you pick: MFA on accounts that matter, rate limits on the login endpoint, and no tokens in
localStorage.
If you'd rather not build auth at all, a managed provider is a perfectly good choice for many products. Just know which of these two models it's using under the hood, because the revocation trade-offs come with it.
Getting it right the first time
Authentication is one of the few parts of an app where a small mistake can expose everything, and it's much cheaper to get right at the start than to retrofit. It's part of every MVP / Web App build we do, and if you've already got an app and want a second pair of eyes on its login, sessions and tokens, that's exactly what our Tech Audit covers. For more backend detail, see our guide to Stripe manual capture and card holds, which covers webhooks and idempotency keys.
Building something with a login and want it done properly? Tell us about it.