Sessions vs JWTs: you are choosing how often you pay for state Developer Maneshwar, creator of LiveReview, argues that the choice between session-based authentication and JSON Web Tokens (JWTs) is fundamentally about how quickly a server can revoke access. Sessions allow instant revocation by deleting a server-side record, while JWTs, being signed but not encrypted, remain valid until expiration, creating a window of vulnerability. The post highlights that JWTs are not encrypted and warns against storing sensitive data in them. 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. 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. 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 . 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. The token has three parts, joined by dots: header, payload, signature. It is specified in RFC 7519 https://datatracker.ietf.org/doc/html/rfc7519 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 } No key. No password. Just base64. A JWT is signed, not encrypted. Anyone holding the token can read every claim inside it. jwt.io https://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. 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. 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. 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? 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. 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: js 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 } ; } ; 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. 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? 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 https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/ . Modern libraries defend against it, but pin the algorithm yourself anyway: jwt.verify token, key, { algorithms: "RS256" } . Never let the token pick. 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 https://cheatsheetseries.owasp.org/cheatsheets/Session Management Cheat Sheet.html is worth twenty minutes here. Start from the constraint, not the acronym. php 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: 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: