{"slug": "sessions-vs-jwts-you-are-choosing-how-often-you-pay-for-state", "title": "Sessions vs JWTs: you are choosing how often you pay for state", "summary": "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.", "body_md": "*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.*\n\nEvery app you have ever built had to answer the same question on every single request.\n\nWho is this, and are they allowed to do this?\n\nThere are two popular answers.\n\nSessions, where the server remembers you. And JWTs, where the server hands you a signed note and promptly forgets you exist.\n\nThe internet has mostly decided that JWTs are the modern one and sessions are what your grandfather used in PHP.\n\nThat framing is wrong, and it leads people into a specific trap that I want to walk you through properly.\n\nLet's start with the flows, because the difference lives in the details.\n\nYou log in. The server checks your password, and if it is happy, it writes a row somewhere.\n\nThat row holds your user id, an expiry, maybe your roles. It lives in Redis, or Postgres, or memory if you are feeling brave.\n\nThen the server sends you back a cookie containing one thing: a random id.\n\nThat is it. The cookie is not your identity. It is a claim ticket.\n\nLook at the bottom half of that diagram, because it is the part that matters.\n\nOn every request after login, the server takes your session id, goes to the store, and asks \"who is this again?\"\n\nYour identity is never in the cookie.\n\nIt is fetched, fresh, every time.\n\nThis has a consequence people underrate: **the server can change its mind about you instantly.**\n\nDelete 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`\n\n.\n\nSame login. Same password check.\n\nBut instead of writing a row, the server builds a small JSON object, signs it, and hands the whole thing to you.\n\nThe token has three parts, joined by dots: header, payload, signature.\n\nIt is specified in [RFC 7519](https://datatracker.ietf.org/doc/html/rfc7519) if you want the formal version.\n\nHere is the single most important thing about that payload, and the thing I see people get wrong in production code:\n\n```\n# grab the middle section of any JWT and just... read it\necho \"$TOKEN\" | cut -d. -f2 | base64 -d 2>/dev/null | jq\n{\n  \"sub\": \"user_8823\",\n  \"email\": \"maneshwar@example.com\",\n  \"role\": \"admin\",\n  \"exp\": 1735689600\n}\n```\n\nNo key. No password. Just base64.\n\n**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.\n\nThe signature does not hide the contents.\n\nIt only proves the contents were not edited after the server signed them.\n\nSo never put anything in a JWT payload that you would not print on a postcard.\n\nNo secrets, no internal flags you would rather users not see, no \"isTrialAbuser\": true.\n\nForget the acronyms for a second.\n\nWith sessions, the truth lives on your server, and the client holds a pointer to it.\n\nWith JWTs, the truth lives in the client's pocket, and your server holds a way to check the handwriting.\n\nEverything else follows from that one sentence.\n\nAdd a second server and you can see it.\n\nSessions 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.\n\nJWTs need no shared anything.\n\nEvery server has the key, every server verifies locally, and adding a fourth server is a non-event.\n\nThis is genuinely great, and it is why JWTs took over microservices.\n\nBut look at the bottom of both columns. That is where you pay.\n\nHere is the question that decides this whole thing, and it is not \"which is more scalable.\"\n\nIt is: **what happens between the moment you decide someone should be logged out and the moment they actually are?**\n\nFor a session, that gap is one request. You delete the row, the next request fails, done.\n\nFor a plain JWT, that gap is however long is left on the clock.\n\nYou can delete the user from your database, disable their account, revoke their API keys, set the building on fire.\n\nThe token still works.\n\nEvery server that sees it will cheerfully verify the signature, find it valid, and serve the request.\n\nThat is not a bug.\n\nThat is the design. Statelessness means no server is checking with anyone, and \"this user is now banned\" is information that lives with someone.\n\nThe standard fix is well known.\n\nMake the access token short-lived, around 15 minutes, and pair it with a long-lived refresh token.\n\nWhen the access token expires, the client quietly trades the refresh token for a new one.\n\nThe user notices nothing.\n\nThe stolen-token window shrinks from days to minutes.\n\nThis genuinely works and you should do it. But sit with the refresh endpoint for a second:\n\n``` js\napp.post(\"/auth/refresh\", async (req, res) => {\n  const { refreshToken } = req.body;\n\n  // here it is\n  const stored = await redis.get(`refresh:${refreshToken}`);\n  if (!stored) return res.sendStatus(401);          // revoked, or never existed\n\n  const { userId } = JSON.parse(stored);\n  const user = await db.users.findById(userId);\n  if (user.disabled) return res.sendStatus(401);    // banned since last refresh\n\n  return res.json({ accessToken: signAccessToken(user) });\n});\n```\n\nCount the things in there.\n\nA store lookup. A revocation check. A trip to the database to see if the user is still allowed in.\n\nThat is a session. You have written a session.\n\nThe 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.\n\nThe difference is you now check it every 15 minutes instead of every request.\n\n**And that is the real answer.** You are not choosing between stateful and stateless.\n\nYou are choosing **how often you are willing to pay for state**, and how long you will tolerate being wrong in between.\n\nSessions pay on every request and are never wrong. Plain JWTs never pay and can be wrong for hours.\n\nRefresh tokens pay occasionally and are wrong for about fifteen minutes.\n\nThe transcript version of this is \"HMAC is symmetric, RSA and ECDSA are asymmetric.\" True, but it buries the point.\n\nThe real question is: **how many services can mint a token?**\n\nWith HMAC, the key that verifies a token is the same key that signs one.\n\nSo 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.\n\nInside 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.\n\nWith RSA or ECDSA, the auth service holds the private key and everyone else gets the public one.\n\nThey can verify all day and cannot produce a single token. A leaked public key costs you nothing, because it is public.\n\nWhile we are here, one footgun worth knowing. The token's own header says which algorithm to use, and historically libraries just believed it.\n\nAttackers set `alg`\n\nto `none`\n\n, or switched an RS256 setup to HS256 so the public key got used as an HMAC secret.\n\nAuth0 wrote up [the classic round of these bugs](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/).\n\nModern libraries defend against it, but pin the algorithm yourself anyway: `jwt.verify(token, key, { algorithms: [\"RS256\"] })`\n\n. Never let the token pick.\n\nThis part gets skipped constantly and it is where most real breaches live.\n\n`localStorage`\n\nis convenient and readable by any JavaScript on your page.\n\nThat means one bad npm dependency or one XSS hole and your token is gone. There is no browser mechanism that stops it.\n\nAn `HttpOnly`\n\ncookie cannot be read by JavaScript at all, which kills that entire class of theft.\n\nThe trade is that browsers send cookies automatically, which is what CSRF exploits, so you need `SameSite=Lax`\n\nor `Strict`\n\nand a token on state-changing requests.\n\nNotice what just happened.\n\nIf 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.\n\nThat is not an argument against JWTs. It is an argument for knowing which property you actually wanted.\n\nThe [OWASP session management cheat sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) is worth twenty minutes here.\n\nStart from the constraint, not the acronym.\n\n``` php\nflowchart TD\n    A[Picking auth] --> B{Need instant revocation?}\n    B -->|Yes| S[Sessions]\n    B -->|No| C{Already run Redis or a shared DB?}\n    C -->|Yes| S\n    C -->|No| D{Many services must verify?}\n    D -->|No| S\n    D -->|Yes| E{Trust every service?}\n    E -->|Yes| H[JWT + HMAC]\n    E -->|No| R[JWT + RSA]\n```\n\nThe short version:\n\nThe 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.\n\nIf you need the properties of a session, use a session. If you need the properties of a token, use a token.\n\nJust do not use a token and then spend six months re-adding the properties of a session to it.\n\nYour 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.\n\nI'm building **LiveReview**, a blast-radius aware AI code review built for your business-critical systems.\n\nInstead 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.**\n\nSpend code review effort where business risk is highest — not spread evenly across every diff.\n\n**Try LiveReview on your codebase:**", "url": "https://wpnews.pro/news/sessions-vs-jwts-you-are-choosing-how-often-you-pay-for-state", "canonical_source": "https://dev.to/lovestaco/sessions-vs-jwts-you-are-choosing-how-often-you-pay-for-state-196m", "published_at": "2026-08-31 17:42:24+00:00", "updated_at": "2026-08-31 17:53:26.440590+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Maneshwar", "LiveReview", "JWT", "RFC 7519", "jwt.io"], "alternates": {"html": "https://wpnews.pro/news/sessions-vs-jwts-you-are-choosing-how-often-you-pay-for-state", "markdown": "https://wpnews.pro/news/sessions-vs-jwts-you-are-choosing-how-often-you-pay-for-state.md", "text": "https://wpnews.pro/news/sessions-vs-jwts-you-are-choosing-how-often-you-pay-for-state.txt", "jsonld": "https://wpnews.pro/news/sessions-vs-jwts-you-are-choosing-how-often-you-pay-for-state.jsonld"}}