DEV Community

Cover image for Sessions vs JWTs: you are choosing how often you pay for state
Athreya aka Maneshwar
Athreya aka Maneshwar

Posted on

Sessions vs JWTs: you are choosing how often you pay for state

Hello, I'm Maneshwar, and I'm building LiveReview — a blast-radius aware AI code review built for your business-critical systems. Star us to help devs discover the project, give it a try, and share your feedback to help improve the product.

Every app you have ever built had to answer the same question on every single request.

Who is this, and are they allowed to do this?

There are two popular answers.

Sessions, where the server remembers you. And JWTs, where the server hands you a signed note and promptly forgets you exist.

The internet has mostly decided that JWTs are the modern one and sessions are what your grandfather used in PHP.

That framing is wrong, and it leads people into a specific trap that I want to walk you through properly.

Let's start with the flows, because the difference lives in the details.

Sessions: the coat check model

You log in. The server checks your password, and if it is happy, it writes a row somewhere.

That row holds your user id, an expiry, maybe your roles. It lives in Redis, or Postgres, or memory if you are feeling brave.

Then the server sends you back a cookie containing one thing: a random id.

That is it. The cookie is not your identity. It is a claim ticket.

Diagram: session auth sequence, showing the login writing to the store and every later request reading from it

Look at the bottom half of that diagram, because it is the part that matters.

On every request after login, the server takes your session id, goes to the store, and asks "who is this again?"

Your identity is never in the cookie.

It is fetched, fresh, every time.

This has a consequence people underrate: the server can change its mind about you instantly.

Delete the row and the very next request from that cookie is a stranger. Ban a user, force a logout, revoke a compromised session, all of it is a DELETE.

JWTs: the signed note model

Same login. Same password check.

But instead of writing a row, the server builds a small JSON object, signs it, and hands the whole thing to you.

Diagram: JWT auth sequence, with a NO STORE column showing there is nothing to write, look up, or delete

The token has three parts, joined by dots: header, payload, signature.

It is specified in RFC 7519 if you want the formal version.

Here is the single most important thing about that payload, and the thing I see people get wrong in production code:

# grab the middle section of any JWT and just... read it
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq
{
  "sub": "user_8823",
  "email": "maneshwar@example.com",
  "role": "admin",
  "exp": 1735689600
}
Enter fullscreen mode Exit fullscreen mode

No key. No password. Just base64.

A JWT is signed, not encrypted. Anyone holding the token can read every claim inside it. jwt.io will do it for you in a browser.

The signature does not hide the contents.

It only proves the contents were not edited after the server signed them.

So never put anything in a JWT payload that you would not print on a postcard.

No secrets, no internal flags you would rather users not see, no "isTrialAbuser": true.

The actual difference is where the truth lives

Forget the acronyms for a second.

With sessions, the truth lives on your server, and the client holds a pointer to it.

With JWTs, the truth lives in the client's pocket, and your server holds a way to check the handwriting.

Everything else follows from that one sentence.

Diagram: three app servers all reaching one Redis, versus three servers each verifying a token locally

Add a second server and you can see it.

Sessions need every server to reach the same store, which means a network hop on every request and one more thing in your architecture that must never go down.

JWTs need no shared anything.

Every server has the key, every server verifies locally, and adding a fourth server is a non-event.

This is genuinely great, and it is why JWTs took over microservices.

But look at the bottom of both columns. That is where you pay.

The part nobody puts on the slide

Here is the question that decides this whole thing, and it is not "which is more scalable."

It is: what happens between the moment you decide someone should be logged out and the moment they actually are?

Diagram: three timelines showing the attacker window for a session, a plain JWT, and access plus refresh tokens

For a session, that gap is one request. You delete the row, the next request fails, done.

For a plain JWT, that gap is however long is left on the clock.

You can delete the user from your database, disable their account, revoke their API keys, set the building on fire.

The token still works.

Every server that sees it will cheerfully verify the signature, find it valid, and serve the request.

That is not a bug.

That is the design. Statelessness means no server is checking with anyone, and "this user is now banned" is information that lives with someone.

Bugs Bunny No meme: the stateless auth layer flatly refusing to revoke a token

So we invented refresh tokens, and something funny happened

The standard fix is well known.

Make the access token short-lived, around 15 minutes, and pair it with a long-lived refresh token.

When the access token expires, the client quietly trades the refresh token for a new one.

The user notices nothing.

The stolen-token window shrinks from days to minutes.

This genuinely works and you should do it. But sit with the refresh endpoint for a second:

app.post("/auth/refresh", async (req, res) => {
  const { refreshToken } = req.body;

  // here it is
  const stored = await redis.get(`refresh:${refreshToken}`);
  if (!stored) return res.sendStatus(401);          // revoked, or never existed

  const { userId } = JSON.parse(stored);
  const user = await db.users.findById(userId);
  if (user.disabled) return res.sendStatus(401);    // banned since last refresh

  return res.json({ accessToken: signAccessToken(user) });
});
Enter fullscreen mode Exit fullscreen mode

Count the things in there.

A store lookup. A revocation check. A trip to the database to see if the user is still allowed in.

That is a session. You have written a session.

The refresh token is an opaque id pointing at server-side state that you can delete at any time, which is the exact definition of the thing we supposedly moved away from.

The difference is you now check it every 15 minutes instead of every request.

And that is the real answer. You are not choosing between stateful and stateless.

You are choosing how often you are willing to pay for state, and how long you will tolerate being wrong in between.

Sessions pay on every request and are never wrong. Plain JWTs never pay and can be wrong for hours.

Refresh tokens pay occasionally and are wrong for about fifteen minutes.

Charlie Day conspiracy board meme: connecting the red string from stateless JWTs back to sessions

Which signing algorithm, and why it is really a trust question

The transcript version of this is "HMAC is symmetric, RSA and ECDSA are asymmetric." True, but it buries the point.

The real question is: how many services can mint a token?

Diagram: HMAC sharing one secret with every service, versus a private signing key and public verify keys

With HMAC, the key that verifies a token is the same key that signs one.

So every service you hand it to can forge a token for any user, with any role, and every other service will accept it as genuine.

Inside one monolith, fine. Across teams, or anywhere near a third party, that is a lot of trust to hand out just so somebody can check a signature.

With RSA or ECDSA, the auth service holds the private key and everyone else gets the public one.

They can verify all day and cannot produce a single token. A leaked public key costs you nothing, because it is public.

While we are here, one footgun worth knowing. The token's own header says which algorithm to use, and historically libraries just believed it.

Attackers set alg to none, or switched an RS256 setup to HS256 so the public key got used as an HMAC secret.

Auth0 wrote up the classic round of these bugs.

Modern libraries defend against it, but pin the algorithm yourself anyway: jwt.verify(token, key, { algorithms: ["RS256"] }). Never let the token pick.

Where you store the token decides how it gets stolen

This part gets skipped constantly and it is where most real breaches live.

localStorage is convenient and readable by any JavaScript on your page.

That means one bad npm dependency or one XSS hole and your token is gone. There is no browser mechanism that stops it.

An HttpOnly cookie cannot be read by JavaScript at all, which kills that entire class of theft.

The trade is that browsers send cookies automatically, which is what CSRF exploits, so you need SameSite=Lax or Strict and a token on state-changing requests.

Notice what just happened.

If you put your JWT in an HttpOnly cookie and check a server-side revocation list, you have arrived back at sessions with extra steps and a bigger cookie.

That is not an argument against JWTs. It is an argument for knowing which property you actually wanted.

The OWASP session management cheat sheet is worth twenty minutes here.

So which one should you use?

Start from the constraint, not the acronym.

flowchart TD
    A[Picking auth] --> B{Need instant revocation?}
    B -->|Yes| S[Sessions]
    B -->|No| C{Already run Redis or a shared DB?}
    C -->|Yes| S
    C -->|No| D{Many services must verify?}
    D -->|No| S
    D -->|Yes| E{Trust every service?}
    E -->|Yes| H[JWT + HMAC]
    E -->|No| R[JWT + RSA]

The short version:

  • Building a normal web app with one backend? Sessions. They are simpler, they revoke instantly, and your framework already ships them. You are not going to outgrow Redis.
  • Handling money, health data, or anything where "logged out now" means now? Sessions, or JWTs with a revocation list, which is sessions wearing a hat.
  • Many services, or third parties, verifying identity without calling your auth service? JWTs. This is what they are for, and it is a real superpower.
  • Chose JWTs? Short access tokens, revocable refresh tokens, asymmetric keys, pinned algorithm, HttpOnly cookie. Sven Slootweg's Stop using JWT for sessions is a useful counterweight to the hype, even where you disagree with it.

The pattern I keep seeing is teams reaching for JWTs because they sound like the scalable choice, then bolting on a revocation list, a refresh store, and a blocklist until they have rebuilt sessions badly.

If you need the properties of a session, use a session. If you need the properties of a token, use a token.

Just do not use a token and then spend six months re-adding the properties of a session to it.



Your team's attention is limited, and the deluge of AI-generated code is making it harder to keep production code safe without slowing you down.

I'm building LiveReview, a blast-radius aware AI code review built for your business-critical systems.

Instead of presenting every diff with equal emphasis, LiveReview scores each change by blast radius — how far its impact reaches through your call graph — so you can focus attention where it actually matters.

Spend code review effort where business risk is highest — not spread evenly across every diff.

Try LiveReview on your codebase:

LiveReview Banner

Top comments (1)

Collapse
 
jescalan profile image
Jeff Escalante

I wrote this piece on how Clerk architects session management, which is kind of wild, and I bet you'd be interested. Not trying to promote anything, purely for the love of session management architecture: clerk.com/docs/guides/how-clerk-wo...