A JWT Is a Cookie With a Superiority Complex

Contents
A junior developer once proudly showed me an authentication system built around JWTs stored in localStorage, explaining that this was “more modern” than the session cookie the previous version used. I asked how they revoked a compromised token before it expired. There was a long pause. That pause is basically the entire JWT story: a genuinely useful piece of engineering, dressed up in enough cryptographic vocabulary — claims, signatures, base64url encoding — that people reach for it reflexively without noticing it traded away a feature the humble session cookie had for free.
A JWT does the same fundamental job a session cookie does: it’s a piece of data your browser hands back to the server on every request, identifying who you are so the server doesn’t have to ask you to log in again. The difference sits entirely in how the server checks whether to trust it, and that one architectural choice is responsible for both everything JWTs are genuinely good at and every footgun people manage to shoot themselves with.
What a session cookie actually does
A traditional session cookie carries almost no information by itself — typically just an opaque, random identifier:
| |
That identifier means nothing on its own. Every request, the server takes a8f5f167..., looks it up in a session store (Redis, a database table, in-memory), and finds the actual data — which user this is, what their permissions are, when the session should expire. The cookie is a pointer; the server holds the source of truth, checked on every single request.
What a JWT does instead
A JWT carries the actual data inside the token itself, cryptographically signed so the server can trust it wasn’t tampered with, without ever looking anything up:
| |
Split on the dots and base64url-decode the first two parts and the structure is plain text:
| |
The header names the signing algorithm, the payload carries whatever claims the server decided to embed, and the third part is a signature over the first two, computed with a secret only the server knows:
| |
Verification here needs no database, no session store, no network call — just the secret key and the maths. That’s the entire pitch: any server holding the shared secret (or, with asymmetric signing, the public key) can verify the token and trust its claims instantly, which is genuinely valuable the moment more than one server needs to check who’s making a request.
The superiority complex, and where it comes from honestly
Session cookies feel humble because they admit they need help — a database, a session store, something to consult. A JWT looks self-sufficient: sign it once, verify it anywhere, no shared state required. That self-sufficiency is a real engineering advantage in a specific situation: microservices where an org chart got wired into an architecture genuinely benefit from a token every service can verify independently, without a network call back to a central session store for every single request between services.
For a single monolithic application talking to one database, that advantage buys nothing that a session cookie and a SELECT against a sessions table didn’t already provide, cheaper and with fewer sharp edges. The superiority complex is earned in distributed systems and entirely performative everywhere else.
The trade-off nobody puts on the label: you can’t take it back
Here is the actual cost of skipping the database lookup, and it’s a real one, not a footnote: a signed JWT is valid until it expires, full stop, and there is no built-in mechanism to revoke it early. Fire an employee, and their session cookie dies the moment you delete the row in the sessions table — the very next request fails the lookup. Fire an employee holding a JWT with a 24-hour expiry, and that token keeps working, fully authenticated, for up to 24 hours, because nothing in the verification process above ever consults a database that could say “actually, revoke this one.”
| |
Every workaround for this reintroduces the exact database lookup JWTs exist to avoid: a token blocklist checked on every request, a short expiry paired with a refresh-token flow that does hit a database on renewal, or a “token version” field on the user record checked against the claim. All three are legitimate, commonly used patterns — and every single one of them quietly re-adds the server-side state a JWT was supposed to let you skip. The honest framing is that JWTs don’t eliminate the need for server-side session state; they let you defer paying that cost until you need revocation, at which point you’re back to building something session cookies gave you from the very first line of code.
localStorage vs a cookie: the argument that actually matters for security
The junior developer’s original mistake compounds this problem rather than being a separate issue: storing a JWT in localStorage makes it readable by any JavaScript running on the page, including injected JavaScript from an XSS vulnerability anywhere on the site. A cookie marked HttpOnly is invisible to JavaScript entirely — a script running on the page cannot read it, even during a successful XSS attack:
| |
Storing a JWT in an HttpOnly cookie captures the actual cryptographic benefit of JWTs — self-contained, independently verifiable claims — without handing an XSS bug a working authentication token to steal. There’s rarely a good reason to put an auth token anywhere JavaScript can touch it, JWT or otherwise; the “modern” localStorage pattern that circulated for years mostly reflected an unfamiliarity with HttpOnly cookies, not a genuine security improvement over them.
Symmetric vs asymmetric signing: who gets to verify, and who gets to issue
The HMAC example above uses a single shared secret for both signing and verifying, which works fine when one server issues tokens and that same server verifies them. It breaks down the moment multiple independent services need to verify tokens without all of them holding the one secret capable of forging new ones — every service trusted to verify is, with HMAC, also trusted to mint valid tokens from scratch, which is a much bigger blast radius than most people realise when they wire up shared-secret JWTs across a service mesh.
Asymmetric signing (RS256 or ES256) splits this cleanly: the authentication server holds a private key and signs tokens with it; every other service holds only the corresponding public key, sufficient to verify a signature but mathematically useless for creating one.
| |
This is the detail that makes JWTs genuinely earn their keep in a multi-service architecture: dozens of services can verify a user’s identity independently, in parallel, with zero shared secret to leak and zero of them capable of impersonating the auth server even if fully compromised. It’s also the detail most tutorials skip past in favour of the simpler HMAC example, which quietly reintroduces a single point of forgery the moment it’s deployed across more than one trust boundary.
The refresh token pattern: paying the database cost back on your own terms
The standard answer to “JWTs can’t be revoked early” in production systems is a two-token pattern: a short-lived JWT (minutes, not hours) used for actual API authentication, paired with a longer-lived refresh token that is checked against a database on every use.
| |
Revoking a user now means deleting their refresh token from the database — the next /refresh call fails, and within fifteen minutes (or whatever the access token’s expiry is) every one of their existing access tokens has also expired naturally and can no longer authenticate anything. This pattern is, honestly, a compromise: it accepts a small window of unrevocable access (the access token’s lifetime) in exchange for keeping the fast, lookup-free verification on the vast majority of requests, while pushing the expensive, revocable check onto the comparatively rare refresh calls. It is not a way around the trade-off described above — it’s a deliberate, tunable version of it, where shortening the access token’s lifetime trades convenience for tighter revocation, and lengthening it trades the reverse.
Troubleshooting: the failure modes specific to JWTs
- A user’s permissions changed but the app still shows their old role. This is the revocation problem showing up in its mildest form — the JWT’s
roleclaim was frozen at issuance and won’t reflect a database change until the token expires and a fresh one is issued. Short expiries plus a refresh flow are the standard fix, trading some of the “no lookup needed” convenience back for correctness. alg: noneaccepted by the server. A historically real vulnerability class: some JWT libraries, if not configured strictly, would accept a token whose header claims"alg": "none"and skip signature verification entirely. Explicitly whitelisting the expected algorithm on the verifying side, rather than trusting whatever the token’s header claims, closes this off completely.- A JWT signed with
RS256gets accepted after being re-signed withHS256using the public key as the secret. Another real historical bug class — if a server accepts either algorithm and uses the public key (which is, by definition, public) as an HMAC secret, an attacker can forge tokens trivially. Pin the expected algorithm explicitly rather than trusting the token to declare its own. - Tokens work fine until the signing secret rotates, then every existing session breaks at once. Unlike a session store, which can support gradual key rotation by checking against multiple valid secrets, a naive JWT rotation invalidates every outstanding token instantly. Supporting a short overlap window with both old and new keys accepted avoids a full logout of every active user simultaneously.
- A refresh token got stolen and there’s no way to tell which access tokens it minted. This is why refresh tokens should be single-use and rotated on every call — issuing a new refresh token alongside every new access token, and invalidating the old refresh token immediately, means a stolen refresh token can be used at most once before the legitimate client’s next refresh call reveals the theft by failing.
Where the size of the token itself becomes a real cost
One more practical difference worth knowing: a session cookie is a handful of bytes, always, regardless of how much data the session actually represents, because the data lives server-side. A JWT’s size scales with however many claims get embedded in it, and every one of those bytes travels on every single request, in every header, forever, until the token expires. A JWT carrying a user’s full permission list, several role flags and a handful of profile fields can easily run past a kilobyte, and that’s a kilobyte added to every API call a client makes for the life of the token — a genuinely measurable cost on mobile connections or high-request-volume APIs that a lookup-based session simply never pays, since the cookie itself stays tiny no matter how much the session object behind it grows.
Is it worth reaching for
A JWT is the right tool exactly when independent verification without a central lookup is the actual requirement — multiple services, mobile clients hitting different backends, third-party API access via OAuth-style delegated tokens. For a standard web application talking to one backend and one database, cookies remain the simpler, more battle-tested mechanism, with instant revocation built in from day one and none of the signing-algorithm footguns above to worry about. The crown JWTs wear is earned in distributed systems and looks a little silly everywhere else — recognising which situation you’re actually in is worth more than picking whichever one sounds more sophisticated in a design document.




