StringToolsStringTools
Back to Blog
SecuritySeptember 7, 2026·9 min read·Mitul Mandanka

JWT vs Session Cookies: Which Should You Actually Use?

By Mitul Mandanka·Reviewed for accuracy·Last updated September 7, 2026

The argument is about where the state lives

Session cookies keep the state on your server and give the browser an opaque ID; JWTs put signed state in the client's hands and your server verifies a signature instead of doing a lookup. For a normal web application, server sessions are the safer default.

Key Takeaways

  • With a session cookie the credential is a pointer. With a JWT the credential is the claim. Almost every practical difference falls out of that one distinction.
  • The stateless-scaling argument is real but usually overstated: a session lookup is one key read from an in-memory store, and most requests already do more expensive work than that.
  • The genuine JWT wins are cross-service and cross-domain trust without a shared store, and verification at an edge.
  • The genuine cookie wins are instant revocation, permission changes that apply on the next request, smaller requests, and far less to get wrong.
  • HttpOnly, Secure and SameSite do most of the security work for cookies. Putting a token in localStorage throws away the equivalent protection.
  • Sensible default: sessions for a normal web app; reach for JWTs when you can name the specific constraint that requires them.

This is a different question from which authorisation scheme you use, covered in OAuth vs JWT vs API keys. The axis here is narrower and more consequential day to day: does your server remember the session, or does the client carry it?

Two mechanisms, one request at a time

Server session with a cookie. On login the server generates a long random identifier from a cryptographically secure source, writes a record against it — user ID, issued-at, roles — and returns Set-Cookie: sid=.... The browser sends that cookie automatically on every matching request; the server reads the ID, looks the record up, and knows who is calling. The record is yours: mutable, inspectable, deletable.

JWT. On login the server builds a payload of claims — typically sub, iat, exp and some notion of scope — and signs it. The client stores that string and presents it, usually as Authorization: Bearer <token>. The server verifies the signature and checks exp locally. No lookup, and therefore no record: the token stays valid until it expires, whatever happens to the account meanwhile. The format is specified in RFC 7519; this post assumes you know the header/payload/signature layout.

Two things follow, and they are the whole debate:

  • The session ID means nothing on its own, so the server must reach the store that gives it meaning.
  • The JWT means something on its own, so anyone with the verifying key can read and trust it — and nobody, including you, can un-mean it before exp.

The stateless-scaling argument, examined

The pitch: no shared session store, so any node serves any request and you scale horizontally without coordination. It is a real property, and the reason most teams choose JWTs. For most of those teams it is not the binding constraint.

A session lookup is cheap. It is a single key read against an in-memory store on the same network — tens of thousands of simple reads per second from one node, with latency dominated by the datacentre round trip rather than the work itself. Set that against the database queries a typical request already makes. If the session read is your bottleneck, it is a remarkable application.

Statelessness is rarely total. Rate limiting, idempotency keys, feature flags, carts, WebSocket presence — most applications already run a shared cache for something. If Redis is already in your stack, the session store is not new infrastructure, just one more key space in infrastructure you already operate.

The revocation workaround puts the state back. The standard answer to "how do I log someone out immediately?" is a deny-list checked on every request — a lookup against a shared store, precisely the thing statelessness removed, except the failure mode of an unreachable store is now "revoked tokens keep working". The JWT revocation problem is worth reading before you settle on short expiry times as the answer.

Where the argument does bite: many services in different trust zones, verification at a gateway with no store nearby, serverless functions with no warm cache connection. Real shapes — and not what most people are building when they reach for a JWT.

What JWTs genuinely win

Trust across services without a shared store. With asymmetric signing the issuer holds the private key and every consumer holds only the public key, so service B verifies a caller's identity without calling the auth service, without sharing its database, and without being able to mint a token itself. Opaque session IDs cannot do that without a shared store or an introspection call on every hop.

Cross-domain and third-party. Cookies are scoped to a domain and governed by the browser. A token in an Authorization header is a string you can send anywhere — a different registrable domain, a partner, a native client with no cookie jar you control.

Rejection at the edge. A gateway or CDN worker can verify a signature and drop bad requests before they touch your application. It cannot resolve an opaque session ID without access to the store.

Inspectability. A JWT's claims are base64url-encoded, not encrypted, so debugging is a matter of reading them — our JWT decoder does that in the browser and works out the expiry. That property is also the warning: anyone holding the token reads every claim, so a JWT is no place for anything you would not print on a postcard. It is a live credential, so inspect an expired or test token rather than a production one.

What session cookies genuinely win

Revocation is a delete. Log out everywhere, password changed, device lost, employee offboarded, account banned — all of it is one record removed, effective on the next request. The most valuable property in the comparison, and the one JWTs cannot give you without becoming stateful again.

State can change under the session. Revoke an admin role, downgrade a plan, accept new terms — the next request sees the new truth. With a JWT, claims are as stale as the token, and a five-minute expiry is a five-minute window in which a demoted user is still an admin.

Smaller requests. A session ID cookie is roughly 30 to 60 bytes. A modest JWT is typically 300 to 800 bytes, grows with every claim, and is sent on every request — including ones that return a 200-byte JSON response. On a mobile connection, on a page making dozens of API calls, that is not nothing.

The client learns nothing. The record can hold internal IDs, a risk score or an impersonation flag, none of which the browser sees. Every equivalent JWT claim is public to whoever holds the token.

There is far less to get wrong. No algorithm pinning, no key rotation, no clock-skew tolerance, no refresh dance, no decision about where to store the credential. You set three cookie attributes correctly and the browser handles transport. Cookie-session failure modes are well understood and thoroughly documented; hand-rolled token auth tends to fail in production.

Side by side

Figures below match those used elsewhere in this post.

DimensionSession cookie (state on server)JWT (state in the token)
RevocationImmediate — delete the recordNot until exp, unless you add a deny-list, which is server state again
ScalingNeeds a shared store; one in-memory key read per requestAny node or edge verifies with a key, no store required
Request size~30-60 bytes~300-800+ bytes, grows with claims
XSS exposureHttpOnly keeps the value out of JavaScript; script can still act as the user in that browserIn localStorage, readable and exfiltratable; in memory, exposed only while the page lives
CSRF exposureReal — needs SameSite and, for sensitive actions, a tokenNone for an Authorization header; full CSRF risk if you put the JWT in a cookie
Cross-domain / third-partyAwkward — cookies are domain-scopedStraightforward — a header travels anywhere
Visible to the clientNothing but an opaque IDEvery claim
ComplexityLow — three cookie attributes and a storeHigher — key management, rotation, algorithm pinning, clock skew, refresh

Read this as trade-offs, not a scoreboard. With one application on one domain, the rows JWTs win are worth nothing to you and the revocation row is worth a great deal.

The localStorage problem

The most common JWT deployment stores the token in localStorage and attaches it in a request interceptor. It is easy, it survives a reload, and it is the weakest part of the design.

localStorage is readable by any script on the page — a larger set than "bugs in my own code", covering every dependency in your bundle, every analytics tag, every embedded widget. One compromised package is enough. And because the token is the credential, the attacker need do nothing further in the victim's browser: they exfiltrate the string and use it from their own machine until exp, and you cannot stop them unless you already run the deny-list that statelessness was supposed to avoid.

The honest contrast with an HttpOnly cookie: both are compromised by XSS, but with a cookie the attacker is confined to that browser session and you end it by deleting one record. With a token in localStorage they hold a portable credential that outlives their access to the page. The stores themselves are compared in localStorage vs sessionStorage vs cookies.

If you are committed to JWTs, the defensible pattern is: keep the access token in a module-scope variable — memory only, never a global, never persisted — give it a short lifetime, and hold the refresh token in an HttpOnly Secure SameSite cookie scoped to the refresh endpoint, rotated on every use. The cost is a refresh round trip on every page load and a queue for requests that fire during it. Worth naming plainly what that is: a cookie-based session with a token cached in memory, and more moving parts than a session table.

Choosing, and being able to defend the choice

Reach for JWTs when you can point at the constraint.

  • Services in different trust boundaries need to verify identity without a shared store or an introspection call on every hop.
  • Your API is consumed across domains, by third parties, or by native clients with no cookie jar you control.
  • Verification has to happen at an edge or in a function with no cache connection.
  • You are already inside an OAuth 2.0 or OpenID Connect flow, where short-lived access tokens are the design rather than a choice you are making.

Stay with server sessions for a web application on one domain with a first-party front end, when immediate revocation matters — money, health data, admin privileges, shared accounts — or when the honest answer to "why JWTs?" is that the tutorial used them.

Mixed estates are normal: session cookies for the browser-facing application, short-lived signed tokens for internal service-to-service calls. The mistake is forcing one mechanism into both jobs.

If you have already shipped JWTs, do not rewrite anything on the strength of a blog post. Fix in this order: get the token out of localStorage, shorten the access-token lifetime, add rotation on refresh, then decide whether the remaining benefit justifies the deny-list you are probably about to build.

Whichever you choose, be able to answer one question concretely: how do I sign a specific user out of every device within a minute, and prove it worked? If there is a clear answer with a runbook behind it, the design is sound. If the answer is "their token expires in fifteen minutes", you do not have a session system — you have a token issuer, and somebody will eventually need the difference.

Frequently Asked Questions

Are JWTs less secure than session cookies?

Not inherently — the format is fine and the signing is sound. The difference is operational. A JWT is a self-contained bearer credential, so a leaked one is usable by anyone until it expires, and the usual storage choice (localStorage) makes leaking easier. A session ID is useless without your store, and you can invalidate it instantly. Most real-world JWT incidents are storage and revocation failures rather than cryptographic ones.

Can I store a JWT in a cookie instead of localStorage?

Yes, and in an HttpOnly Secure SameSite cookie it is a considerably better design than localStorage. Note what you have then built: the browser sends it automatically, so you are back to needing CSRF defences, and you are paying 300 to 800 bytes per request to avoid a lookup you could have done in under a millisecond. It is reasonable when a downstream service needs to verify the token itself, and largely ceremony when nothing does.

How do I log a user out immediately if I use JWTs?

You keep a server-side list of revoked token identifiers (the jti claim) or a per-user "tokens issued before this timestamp are invalid" marker, and check it on every request. That works, and it is also a per-request lookup against shared state — the thing statelessness was meant to remove. Short expiry times shrink the window rather than closing it. The JWT revocation problem goes through the options in detail.

Does HttpOnly protect me from XSS?

It limits the damage, it does not prevent the attack. Script running on your page can still make same-origin requests that carry the cookie, so an attacker can act as the user for as long as the page is open. What HttpOnly stops is exfiltration: they cannot read the cookie value and replay it later from elsewhere. Fix the XSS; treat HttpOnly as containment, not a cure.

Do session cookies work for mobile apps and third-party API clients?

Poorly. Cookies rely on browser behaviour — automatic attachment, domain scoping, SameSite enforcement — that native and server-to-server clients do not reproduce naturally. This is one of the clearest cases for tokens. A common arrangement is session cookies for the web front end and signed tokens for API clients, issued by the same service.

Is the stateless argument for JWTs just wrong, then?

No, it is narrower than it is usually presented. Removing a shared store genuinely helps when verification happens somewhere that cannot reach one: an edge worker, a partner's service, a cold serverless function, a service in a different trust zone. It helps much less when your application already runs a cache for rate limits and carts, and one more key read per request changes nothing you can measure.

Sources and references

RFC 7519 (datatracker.ietf.org) · RFC 6265 (datatracker.ietf.org) · Session Management Cheat Sheet (cheatsheetseries.owasp.org). Content was reviewed against these sources as of the last-updated date above; external figures and rules may change after publication.