JWT vs Session Tokens
Two ways to answer the same question - who is making this request? They work differently enough that the choice between them shapes a lot of what you build on top. Here's how each one works, what separates them, and where each one fits.
Recently I've been learning more about backend to widen my scope, and one of the topics I ran into was JWT and session tokens.
At first I thought I'd just look up how to use them in code. But I'm probably not writing backend code any time soon, and that was never really the point. The point of learning backend was to understand what's happening underneath, so that when I build a fullstack app or design a service, I can pick the right thing for the job. Knowing the difference between these two helps me work out where to use them and, more importantly, when.
So this post is that - how sessions work, how JWTs work, what separates them, and where each one fits.
What a session actually is
The idea behind a session is simple.
You log in. The server generates a long random string - no meaning, no structure, just a random value. Something like a1b2c3d4e5f6. It writes a row somewhere it controls (Redis, Postgres, memory if you're feeling brave, basically anywhere the data stays consistent across servers) that maps that string to who you are. Then it hands you the string in a cookie.
On every request after that, your browser sends the cookie back automatically, the server looks the string up, and finds out who you are.
The important part: the token means nothing on its own. If you steal a1b2c3 and the server has deleted that row, you're holding a dead string. The session ID is a pointer. The server holds the truth.
That's what people mean when they call sessions "stateful". The state isn't in the token, it's in the store, and the server has to go look at it.
What a JWT actually is
A JWT (JSON Web Token) flips that around. Instead of giving you a pointer to the data, the server gives you the data - and signs it so you can't change it.
A JWT is three base64url-encoded chunks joined by dots:
The header says which algorithm signed it. The payload holds the claims - who this is, what they can do, when it expires. The signature is a hash of the first two parts plus a secret only the server knows.
When a request comes in, the server recomputes the signature from the header and payload it just received. If that matches the signature on the token, nothing has been changed. If someone flipped "role": "user" to "role": "admin", the signature won't match and the token is rejected.
No store. No lookup. As long as it has the secret (or the public key, for RS256), the server can verify a JWT on a machine that has never seen this user and has no database connection at all.
That is the reason JWTs exist.
One thing worth being clear about
A JWT is signed, not encrypted. Those chunks are base64url, which is an encoding, not a cipher. Anyone holding the token can decode it and read every claim inside. Paste one into jwt.io and it shows you the payload in plain text, no secret required.
The signature proves integrity - nobody changed this. It proves nothing about confidentiality.
So anything you put in the payload is readable by whoever holds the token: the user, and anything that manages to steal it. Email addresses, plan tiers, internal IDs - all visible. That's not a flaw, just a property of the format that's easy to miss because base64 looks scrambled.
What actually separates them
There are plenty of smaller differences - payload size, database load, how each behaves across domains. But they all come out of one big one:
You can delete a session. You cannot un-issue a JWT.
You revoke a session by deleting a row. One command, effective on the very next request. Ban a user, log someone out of every device, kill a session after a password reset - all one delete.
A JWT stays valid until it expires, because validity lives in the token itself, not in anything the server is tracking. There's no row to delete. Issue a token with a 24-hour expiry, fire the user an hour later, and that token keeps working for 23 more hours on every service that trusts your signature.
Most of the extra machinery around JWTs exists to shrink that window. Short expiry times, refresh tokens, denylists, tokens_valid_after timestamps - they're all ways of getting back some ability to say "no, not that one, not anymore."
So the trade is this: a session pays for a lookup on every request and gets instant revocation in return. A JWT skips the lookup and gives that up.
Side by side
Session JWT
-------------------- ----------------------
token contents random ID, no the claims themselves
meaning of its own
server state one row per login none, by default
infra to run a store to operate just a signing secret
verification store lookup local signature check
revocation delete the row expiry only
claim freshness always current frozen at issue time
size on the wire ~32 bytes ~300 bytes to a few KB
scaling out needs a shared store trivially stateless
non-browser client cookies get awkward just a header
typical XSS risk HttpOnly hides it localStorage is
from JavaScript readable by any scriptThree of those rows are worth expanding.
Claim freshness. Put "role": "admin" in a JWT, then demote that user, and the token still says admin until it expires. With a session, the role gets read fresh on every request, so the demotion applies right away. Any claim you bake into a token is a claim that can go stale for the life of that token. For a display name, fine. For permissions, worth thinking about.
Size. A session cookie is tiny. A JWT with a few claims is a few hundred bytes, and one carrying a permissions array can run into kilobytes - sent on every request, including the ones for images and polling. It rarely breaks anything, but it does grow as people keep adding claims.
Storage. Sessions need somewhere to live, and that somewhere is real infrastructure: a Redis instance or a table, provisioned, monitored, backed up, expired, and reachable from every server handling a request. JWTs need none of that - the signing secret is config, not a database. That's real work on one side and no work on the other, and it's often the difference you feel most day to day.
The refresh token pattern
The usual way to shrink the revocation window is to use two tokens:
The access token is short-lived, so a stolen one is only useful for a few minutes. The refresh token is long-lived, but it's opaque and stored server-side, so it can be deleted. When the access token expires, the client posts the refresh token to /refresh, the server checks the store, and issues a new access token.
It's worth noticing what that refresh token actually is: a random opaque string, kept on the server, checked when it's used, deleted when you want it gone. That's a session. The short-lived JWT just sits in front of it so most requests can skip the lookup. Which means "we use JWTs, so we're stateless" describes one hop, not the whole system.
That's a fine design, and it's what most production JWT setups run. It just brings the storage question back in a smaller form - one row per login instead of one lookup per request.
Where each one fits
Neither is an upgrade on the other. It comes down to which properties you need.
Sessions fit when the server can always reach the store and auth state has to be current:
- Internal tools and dashboards, where access should change the moment someone's role does
- Anything where an admin needs to kill a login immediately
- One backend, or a few services behind shared infrastructure, where the lookup is cheap anyway
JWTs fit when whatever is verifying the token can't reasonably ask whoever issued it:
- Service-to-service auth across a mesh, where every hop hitting a central auth store is a real cost
- Federated identity - an OIDC
id_tokenfrom Google is a JWT precisely because your server needs to verify it without calling Google - Third-party clients using your API, where you don't control the caller
- Edge or serverless verification, where the code runs in many places and keeping a warm connection to a store is awkward
- Anywhere you'd rather not run a session store at all
Short-lived signed tokens are a smaller JWT use that came up less in what I read: password reset links, email verification, signed download URLs, one-time invites. Something that expires in 15 minutes, does one job, and where revocation never comes up because the token is about to be worthless anyway.
Hybrids cover the case where you want JWTs and revocation. Put a jti in every access token and keep a denylist, or keep a tokens_valid_after timestamp on the user row and reject any token issued before it. "Log out everywhere" becomes a single timestamp update, at the cost of a lookup.
Wrapping up
What took me longest to unlearn was thinking of JWTs and sessions as opposites. They aren't, really. A session is a way of tracking auth state. A JWT is a way of carrying claims you can verify. You can build session-like systems out of JWTs, and plenty of people do - the refresh token pattern is exactly that.
What's actually being traded is small: a lookup on every request, in exchange for auth state that's always current and can be revoked whenever you want. Everything downstream - refresh tokens, denylists, 15 minute expiries, stale role claims - follows from which side of that you picked.
Where I'm at with it
For what it's worth, I've only ever used JWTs.
The reason isn't the one people usually give. It wasn't scale or statelessness - it was that a JWT has almost no storage attached to it. The signing secret is config. There's no Redis instance to provision, no table to migrate, no expiry job, no monitoring for a store that becomes a single point of failure for every logged-in user. Running a session means running and maintaining that data.
That's a real reason and I don't think it's a bad one. It's also a narrow one - it's about how much I have to run and look after, not about which design is better. If I were building something where an admin had to kill a login immediately, I'd choose differently, and the storage would just be the price of that.
Mostly, what I got out of digging into this is that the choice makes sense to me now. Understanding why you can't revoke a JWT explains nearly everything built on top of it. Once that clicked, refresh tokens and denylists and short expiries stopped looking like a list of best practices and started looking like what they are - consequences of one property, which you either want or you don't.