Sessions vs JWT: The Real Tradeoff
Here is a support ticket that will ruin your afternoon. A user writes in: my account was compromised, I have changed my password, please make sure the attacker is logged out of everything right now.
With server sessions you run one DELETE, and it is genuinely over. With the JWT setup a lot of teams ship without thinking, you cannot honestly make that promise. The attacker’s token keeps working until it expires, and there is nothing on your server to delete. That single difference is what this whole tired debate is actually about.
So let’s have the argument properly, once, and then stop having it. The short version, which I will spend the rest of the article earning: sessions are underrated, JWT is over-applied, and most web apps reach for tokens because they read a blog post, not because they had a problem sessions could not solve.
What a session actually is
A server session is almost embarrassingly simple. When a user logs in, you generate a long random string that means nothing. You store the real state (who they are, when the session expires) in a place only your server can read. You hand the random string back to the browser in a cookie. That string is the whole “credential”, and on its own it is just noise.
import { randomBytes } from "node:crypto";
// on login, after the password check
const sid = randomBytes(32).toString("base64url"); // 256 bits of nothing
await store.set(`sess:${sid}`, { userId: 42 }, { ttlSeconds: 60 * 60 * 24 * 14 });
res.setHeader(
"Set-Cookie",
`sid=${sid}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${60 * 60 * 24 * 14}`,
);
Every request after that carries the cookie automatically. Your job on each request is one lookup: take the id, ask the store who it belongs to, and if the store shrugs, it is a 401.
const sess = await store.get(`sess:${req.cookies.sid}`);
if (!sess) return res.status(401).end();
req.userId = sess.userId; // now the request is authenticated
The store can be Redis, Postgres, a library_sessions table, whatever you already run. The important part is where the truth lives: on your side. The id in the browser is a claim ticket, not the coat.
Two properties fall out of this design for free, and they are the reasons sessions are so good and so underrated.
Revocation is a DELETE. Logging a user out, banning an account, killing every device after a password change: all of it is removing a row. The next request finds nothing and fails. There is no window, no grace period, no “eventually”. It is instantaneous and total.
await store.del(`sess:${sid}`); // this device
await store.del(`user_sessions:${userId}`); // or every device you tracked for them
A stolen id is worthless once it expires. The string in the cookie decodes to nothing. Steal it after the store entry is gone and you are holding a dead ticket. There is no secret baked into it that keeps working, because the id was never the secret. The store was.
The “sessions don’t scale” objection is mostly folklore
The stock argument against sessions goes: every request hits a database, so at scale you drown in lookups. People repeat it as if it settles the matter. It mostly does not.
A session lookup is a single read on a primary key. Redis serves that in a few hundred microseconds and handles well over a hundred thousand of them per second on one node. A Postgres index lookup on a text primary key is in the same ballpark once the row is cached. If your service does anything at all (renders a page, runs a business query, calls another API), the session read is not close to being your bottleneck. Your actual queries are.
The genuinely stateless dream (any server can serve any request with zero shared state) is real, but you buy it with a specific currency: you give up cheap revocation. Whether that trade is worth it is the actual question, and it has almost nothing to do with lookup performance.
What a JWT actually is
A JSON Web Token is three base64url chunks joined by dots. Header, payload, signature. The first two are just JSON that anyone can read. The third is a signature over the first two, computed with a key only your server holds.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiI0MiIsImV4cCI6MTh9.3Rf1qk9Z
header (JSON) payload (JSON) signature
The point of the signature is verification without a lookup. Your server can take a token it has never seen before, check that the signature matches using its key, and if it does, trust every claim inside without asking a database anything. That is the one real superpower, and it is genuinely useful in the right place. Hold that thought.
Here is the part people get catastrophically wrong. base64url is not encryption. It is not even obfuscation. It is the encoding you use to make bytes URL-safe, and decoding it is a one-liner in any language, no key required.
Try it yourself. The decoder below builds a real HS256 token on load, decodes the header and payload with no key at all, and verifies the signature with Web Crypto. Then hit “make me admin”, which flips one claim in the payload and reassembles the token. The signature no longer matches, because you do not hold the key.
The lesson is not “JWTs are insecure”. It is that the signature is the entire security boundary. Everything before it is a postcard.
Verifying is the whole job, and it is easy to get wrong
Because the signature is the only thing standing between a user and full impersonation, verifying it correctly is not a detail you can be casual about. A signed token whose signature you did not actually check is worth exactly as much as an unsigned one.
Two classic attacks come from getting this wrong, both documented in the IETF’s JWT best-practices note (RFC 8725).
The alg: none attack. The header names its own algorithm. A token can declare "alg": "none" and ship with an empty signature. Naive libraries read that field, see “no algorithm”, and happily “validate” a token that was never signed. Anyone can then forge any claims they like.
Algorithm confusion. A server that verifies RSA-signed tokens (RS256) holds a public key, which is not secret. An attacker changes the header to HS256 and signs the token using that public key as an HMAC secret. A library that trusts the header’s alg verifies it with HMAC and the public key it already has, and the forgery passes.
Both attacks share one root cause: trusting the alg field in the token you are trying to authenticate. The token is the thing you do not trust yet. Reading instructions from it is like asking a stranger for the password to their own account.
The fix is boring and absolute: pin the algorithm on the server and pass it as an allowlist. Never let the token choose. A current library like jose makes the right thing the default, as long as you pass algorithms.
import { jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"], // pin it. the header does not get a vote
issuer: "https://api.example.com", // checks the iss claim
audience: "https://app.example.com", // checks the aud claim
});
That call also checks the claims that make a token safe to trust over time. You do not get these for free just because the signature matched.
exp(expiry): a Unix timestamp after which the token is dead. If you skip this check, tokens live forever.joseenforces it automatically.iat(issued at): when it was minted. Useful for rejecting tokens older than some policy allows.iss(issuer): who signed it. Check it so a token from a different system in your fleet cannot be replayed against this one.aud(audience): who it was for. A token minted for your billing API should not authenticate against your admin API. Checkingaudis what stops that.
The problem nobody puts on the marketing page
Here is the catch that the “stateless auth” pitch skips right over. You cannot revoke a JWT.
Once you have signed a token and handed it out, it is valid until exp, full stop. There is no server-side record to delete, because avoiding that record was the entire point. Log a user out, change their password, ban them for fraud: none of it touches a token already in the wild. It keeps working until it expires on its own.
So teams do the obvious thing. They keep a denylist: a set of token ids (the jti claim) that have been revoked, checked on every request. Logout adds the token’s jti to the list. Now revocation works again.
Look closely at what just happened. To make tokens revocable, you added a store that every request has to read. That is a lookup per request, against server-side state, exactly the thing you adopted JWTs to avoid. You have reinvented sessions, kept the parts that make forgery attacks possible, and added a signature step on top.
Where JWT genuinely earns its place
None of this makes JWTs bad. It makes them a specialized tool that got sold as a default. The specialization is real, and when your problem matches it, nothing else fits as well.
Service-to-service calls with no shared store. Service A needs to prove to Service B that a request is legitimate, and they do not share a session database (different teams, different clouds, a partner’s API). A short-lived signed token that B verifies with A’s public key is exactly right. No shared state, no network call to a session store, verification is local. This is JWT’s home turf.
Short-lived access tokens in an OAuth flow. This is what JWTs were designed for. The access token is deliberately tiny-lived (minutes), so the fact that you cannot revoke it barely matters: it expires before revocation would have mattered anyway. The long-lived thing, the refresh token, is where you keep the state and the control.
A third party has to verify your tokens. If identity providers, mobile clients, or external services need to check a token without calling you, a signature they can verify against your public key beats an opaque id only you can resolve.
Notice the shape of all three: you either cannot share a store, or the token lives so briefly that revocation is moot. If neither is true, the JWT is solving a problem you do not have.
Access and refresh, the dance that makes it safe
The grown-up version of token auth splits the job in two. A short-lived access token (a JWT, minutes long) authorizes requests. A long-lived refresh token (opaque, stored server-side) exists only to mint new access tokens. You accept that the access token cannot be revoked, precisely because it dies so fast, and you put all your control on the refresh token, which is stateful.
When the access token expires, the client quietly presents its refresh token and gets a fresh pair. The current OAuth security best practice (RFC 9700, published January 2025) says to rotate on every refresh: issue a new refresh token each time and invalidate the old one immediately.
Rotation gives you a theft detector for free. If an attacker steals a refresh token and uses it, they get a new one and the real user’s copy is now stale. The moment the real user (or the attacker) presents the old, already-rotated token, the server sees a refresh token it retired and knows something is wrong. The correct response is to revoke the entire token family and force a fresh login. You could not build that alarm on a stateless token alone. It works precisely because the refresh side is stateful.
Where to actually keep the token
Say you have decided on tokens. Where does the browser hold them? This is where a shocking number of tutorials steer you into a wall.
The wall is localStorage. It is convenient, it is easy, and any JavaScript running on your page can read it. That is the whole problem. One cross-site scripting hole, one compromised npm dependency that runs in the browser, and the attacker’s script reads your token straight out of localStorage and exfiltrates it. Now they have the credential itself, and they can use it from their own machine, at their leisure, until it expires. Storing an auth token in localStorage is a bet that you will never, ever have an XSS bug. That is not a bet I would make.
An HttpOnly cookie is the better default, and the difference is sharp. HttpOnly means JavaScript cannot read the cookie at all, document.cookie does not see it. An XSS attacker on your page can still make requests that ride the cookie (the browser attaches it automatically), which is bad, but they cannot steal the credential and walk away with it. They are trapped inside your page’s session instead of holding a portable key. That containment is worth a lot.
Set-Cookie: sid=Rk9f2q...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=1209600
HttpOnlykeeps JavaScript out, which is the XSS mitigation.Securemeans the cookie only travels over HTTPS.SameSite=Laxis the browser default and blocks the obvious cross-site request forgery vectors while keeping normal top-level navigation working.- For extra hardening, name the cookie with a
__Host-prefix. The browser then refuses to accept it unless it isSecure, hasPath=/, and carries noDomain, which pins it to the exact host that set it.
Cookies are not automatically safe (CSRF is their failure mode, and defending against it is its own topic), but “an HttpOnly cookie plus a strong Content Security Policy” beats “a token in localStorage” on the axis that matters most: whether one bug hands an attacker a stealable credential.
What this project uses, honestly
The library behind this course, the part that lets a buyer come back and re-download their PDFs, uses server sessions, and it is worth being straight about that instead of pretending we practice something fancier.
When you click the magic link in your email, the server mints an opaque token, ls_ followed by 40 random characters, and writes one row to a library_sessions table with your email and an expiry. The token is a bearer credential the browser holds and sends back. Verifying it is exactly the lookup from the top of this article.
// create on login
const token = `ls_${nanoid(40)}`;
await db.insert(librarySessions).values({ id: token, email, expiresAt });
// verify on every request: no key, just a lookup
const row = await db.query.librarySessions.findFirst({
where: and(eq(librarySessions.id, token), gt(librarySessions.expiresAt, new Date())),
});
return row ? row.email : null;
The token means nothing on its own, the truth is the row, and revoking any buyer’s session is a single DELETE. The one wrinkle worth naming: it rides in an Authorization: Bearer header rather than an HttpOnly cookie, because the library page talks to a separate store service. For a same-origin app I would reach for the cookie. There is no JWT anywhere in it, and there was never a reason to add one.
So which one?
A browser talks to your own backend
You want logout to be instant and total
You already run a database or Redis
You value boring over clever
Services verify each other with no shared store
The token is short-lived by design (OAuth access)
A third party must verify without calling you
You can genuinely live without instant revocation
The honest default for a normal web app with a browser and a backend you control is server sessions. They are simpler, they revoke instantly, a stolen expired id is worthless, and the scaling objection evaporates the moment you have the Redis or the table you already have. Reach for JWT when you hit one of the specific constraints on the right, and when you do, verify the signature properly, pin the algorithm, check exp, aud, and iss, keep access tokens short, rotate refresh tokens, and store nothing sensitive in a payload the whole world can read.
Pick the tool for the problem in front of you, not the one the loudest blog post recommended. Most of the time, that tool is a session.
Summary
- A session is an opaque random id in a cookie plus server-side state. The id is meaningless on its own; the server looks it up on every request.
- Revocation is a
DELETE, so logout is instant and total, and a stolen id is worthless once its store entry is gone. - “Sessions don’t scale” is mostly folklore. A keyed lookup in Redis or an indexed table is not your bottleneck, and you almost always run a store already.
- A JWT is
header.payload.signaturein base64url. The signature (with your key) is the only security boundary; the header and payload are readable by anyone, so never put secrets in them. - Verify properly or not at all: pin the algorithm server-side (defeats
alg: noneand algorithm confusion), and checkexp,iss, andaud. A valid signature alone is necessary, not sufficient. - You cannot revoke a JWT. Bolting on a denylist to fix that adds back a per-request store lookup, which is a session with extra steps.
- JWTs genuinely win for service-to-service calls, short-lived OAuth access tokens (paired with rotating refresh tokens), and when a third party must verify without calling you.
- Keep tokens in an
HttpOnlycookie, notlocalStorage, which one XSS bug turns into a stolen credential. AddSecure,SameSite=Lax, and a__Host-prefix. - For a normal browser app talking to your own backend, use sessions. Reach for JWT only when a specific constraint makes sessions a bad fit.