JWT or session cookies: which should you use?
A session cookie keeps state on the server; a JWT carries signed claims. Compared on revocation, browser storage, XSS, CSRF and the cookie attributes to set.
Intermediate7 min read
SESSION COOKIE the state lives on the server ┌─ server ─────────┐ ┌────────┐ Cookie: sid=7f3a9c │ session store ● │ │ browser│ ──────────────────────────────── │ │ └────────┘ │ 7f3a9c user 42 │ │ │ └──────────────────┘ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ JWT the state lives inside the token ┌─ API ────────────┐ ┌────────┐ Bearer eyJhbGciOi... │ [ ] signature │ │ browser│ ──────────────────────────────── │ [ ] exp, aud │ └────────┘ │ no lookup │ │ exp 10:15 │ └──────────────────┘ logout ends a session at once; a JWT lives until exp
On this page
The answer first. A session cookie holds a random session ID; the session data lives on the server, which looks the ID up on every request. A JWT is a token that carries its own signed claims, so the server can check the signature and expiry without looking anything up.
The two are not strict alternatives, since a JWT can travel in a cookie too. The real questions are whether you need to cancel access immediately when a user logs out or a token leaks, and where in the browser the credential lives. For a web app talking to its own backend, I start with a session cookie. The sections below explain why.
SESSION COOKIE the state lives on the server ┌─ server ─────────┐ ┌────────┐ Cookie: sid=7f3a9c │ session store ● │ │ browser│ ──────────────────────────────── │ │ └────────┘ │ 7f3a9c user 42 │ │ │ └──────────────────┘ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ JWT the state lives inside the token ┌─ API ────────────┐ ┌────────┐ Bearer eyJhbGciOi... │ [ ] signature │ │ browser│ ──────────────────────────────── │ [ ] exp, aud │ └────────┘ │ no lookup │ │ exp 10:15 │ └──────────────────┘ logout ends a session at once; a JWT lives until exp
How does a session cookie work?
The server generates a random ID, stores the user's session data under that ID, and hands the ID to the browser in a cookie. RFC 6265 describes the pattern: instead of putting session information in the cookie itself, servers commonly store a nonce (or "session identifier") in a cookie and use it as the key to look up state (section 8.4).
After a successful login, the response carries something like:
HTTP/1.1 200 OK
Set-Cookie: __Host-sid=7f3a9c...; Path=/; Secure; HttpOnly; SameSite=Lax
From then on the browser attaches Cookie: __Host-sid=7f3a9c... to every request to that host. The server finds the ID in its store and knows who is calling.
The benefit RFC 6265 points out is that a leaked ID is useful only for interacting with the server; it contains no user data. That holds only if the ID cannot be guessed. OWASP says session identifiers must have at least 64 bits of entropy, and that the ID must be renewed or regenerated by the web application after any privilege level change, login above all, to stop session fixation, a risk RFC 6265 also warns about.
How does a JWT work?
RFC 7519 defines a JWT as a compact, URL-safe means of representing claims to be transferred between two parties. The claims are a JSON object inside a JWS (signed or MACed) or a JWE (encrypted). The common case is a JWS: three base64url parts joined by dots, for header, payload and signature.
Decoded, the header and payload look like this:
{"alg": "ES256", "typ": "JWT"}
{"sub": "42", "aud": "api.example.com", "exp": 1762160400, "jti": "a81f..."}
Two claims matter most here. exp identifies the expiration time on or after which the JWT MUST NOT be accepted for processing, and jti provides a unique identifier for the JWT that can be used to prevent replay.
Three common mistakes
- Assuming a signature hides the contents. It does not. OWASP notes that the contents of JWTs are base64 encoded, but is not encrypted by default. Anyone holding the token can read the roles or email inside it.
- Trusting the header. RFC 8725 records real attacks:
algchanged to"none", which some libraries would trust and accept without checking a signature, orRS256changed toHS256, so that a library tries to validate using the RSA public key as the HMAC shared secret. The fix: libraries MUST enable the caller to specify a supported set of algorithms and MUST NOT use any other algorithms. - Skipping the audience. When one issuer serves several applications, the JWT MUST contain an "aud" (audience) claim and each relying party must check it. Otherwise a token issued for one app works at another.
Does logout revoke the token straight away?
With a session cookie, yes. The server deletes the session row; the next request with that ID finds nothing. The ID stops working at once, whoever has a copy of it.
With a JWT, no. OWASP describes this as inherent to JWTs: a token only becomes invalid when it expires. Deleting the token from the browser at logout clears that tab, but if the token was copied, an API that checks only the signature and exp keeps accepting it until exp.
The fix OWASP gives is a denylist that keeps a digest (SHA-256 encoded in HEX) of the token with a revocation date until the token would have expired. It works, but now every request needs a lookup, which puts server-side state back in, just as with sessions. The other option is a short exp, accepting that a token cannot be cancelled within that window.
Session timeouts give a number to compare against. OWASP gives common idle timeouts of 2-5 minutes for high-value applications and 15-30 minutes for low risk applications. A JWT that lives far longer than that means a leaked token outlives a session that was already logged out.
Where should the browser keep it?
There are three realistic places, each with a different risk:
| Storage | Readable by JavaScript? | Sent automatically? | After the browser closes |
|---|---|---|---|
Cookie with HttpOnly |
No | Yes, on every matching request | Gone if no Expires/Max-Age |
localStorage |
Yes, by any script on the origin | No, the app adds a header | Still there |
sessionStorage or a JavaScript variable |
Yes | No | Gone with the tab |
Where that comes from: HttpOnly tells the browser to omit the cookie when providing access to cookies via "non-HTTP" APIs, yet the cookie will still be sent with JavaScript-initiated requests such as fetch(). Data in localStorage is accessible by pages which are loaded from the same origin and is persisted across browsing sessions. And cookies are session cookies if they do not specify the Expires or Max-Age attribute, removed when the client shuts down, although MDN warns that a browser's session restore feature can bring them back.
How do XSS and CSRF affect each option?
They hit different places. XSS means an attacker's script runs inside your page. CSRF means another site makes the user's browser send a request to you, cookies included.
Credential in a cookie
The main risk is CSRF. RFC 6265 explains that browsers attach cookies even if the remote party does not know the contents of the cookies (section 8.2), so a form on another site can POST to your API with the user's rights.
The main control is the SameSite attribute. It is still specified in the RFC 6265bis draft (draft -21, September 2025), not yet an RFC. In that draft, Strict means the cookie will only be sent along with "same-site" requests; Lax also allows cross-site top-level navigations; None sends it everywhere. The draft also calls it prudent to ensure that SameSite is not the extent of a site's defense against CSRF (section 8.8.1). Without the attribute the default depends on the browser; MDN notes that some browsers use Lax as the default value. Set it explicitly.
Against XSS, HttpOnly stops a script from reading the cookie and replaying it elsewhere. OWASP calls this mandatory to prevent session ID stealing through XSS attacks, and adds that requests sent from the compromised page will include the session cookie. HttpOnly prevents theft, not use from inside the page.
Credential in JavaScript, sent as a header
If the app keeps a JWT in JavaScript and sends Authorization: Bearer ... itself, the browser never attaches it to requests that another site creates, so classic CSRF does not apply. XSS, on the other hand, can read the token outright and use it from another machine until it expires.
The OWASP cheat sheet as of mid-2025 describes a combined approach: keep the JWT in sessionStorage or a closure, and add a "user context", a random string generated during the authentication phase sent to the browser as a hardened HttpOnly + Secure cookie, with its SHA-256 hash stored in the token. A stolen token without that cookie is rejected. It works, but you now look after a token and a cookie.
Which cookie attributes should you set?
Whether the cookie holds a session ID or a JWT, this is my checklist:
-
Secure: sent only over a secure channel (typically HTTP over Transport Layer Security (TLS)). -
HttpOnly: scripts cannot read it. -
SameSite=LaxorStrict: set it yourself instead of relying on browser defaults. - A
__Host-name prefix: in the 6265bis draft, such a cookie will have been set with a Secure attribute, a Path attribute with a value of /, and no Domain attribute, so it belongs to one host and a sibling subdomain cannot overwrite it. -
Max-Ageno longer than the session or token lifetime: if bothExpiresandMax-Ageare set,Max-Agehas precedence. - Size: browsers should support at least 4096 bytes per cookie, and cookies go out with every request, so a JWT with many claims makes every request larger.
So which should you use?
OWASP's own advice: if your application does not need to be fully stateless, you can consider using traditional session system provided by all web frameworks. My rule of thumb:
Use a session cookie when
- the browser talks directly to your own backend;
- logout or account suspension must take effect immediately;
- you already have, or can add, a session store.
Use a JWT when
- several services that share no store must each verify the credential;
- you can live with a token being valid until
exp, or you have a revocation mechanism in place; - your code checks
alg,audandexp, and keeps secrets out of the payload.
You can also combine them: the browser holds a session cookie for the app's backend, and the backend uses short-lived tokens to call other services. That way no token ever sits in the browser.
Sources
- RFC 7519: JSON Web Token (JWT)
- RFC 8725: JSON Web Token Best Current Practices
- RFC 6265: HTTP State Management Mechanism
- draft-ietf-httpbis-rfc6265bis-21: Cookies: HTTP State Management Mechanism (September 2025)
- OWASP Session Management Cheat Sheet
- OWASP JSON Web Token Cheat Sheet for Java (May 2025 version)
- MDN: Set-Cookie header
Keep reading
Related posts
Building software
RPC vs REST: what's the difference, and which should you use?
RPC calls a named procedure on a server; REST sends requests to resources through HTTP's uniform interface. A side-by-side comparison with gRPC and JSON-RPC examples, from Fielding, RFC 9110 and the specs.
Beginner10 min read
- API
- RPC
- REST
- gRPC
- JSON-RPC
- HTTP
Building software
WebSocket vs SSE vs polling: how each works and when to use it
Polling sends a new request each round, SSE holds one response open for server events, WebSocket upgrades to two-way frames. Reconnects, proxies and firewalls.
Intermediate9 min read
- WebSocket
- Server-Sent Events
- SSE
- Long Polling
- HTTP
- Reverse Proxy
- nginx
Building software
How do OAuth 2.0 and OpenID Connect differ?
OAuth 2.0 gets an app an access token to call an API; OpenID Connect adds an ID token that says who signed in. The code flow with PKCE, request by request.
Intermediate7 min read
- OAuth 2.0
- OpenID Connect
- PKCE
- Authentication
- Authorization
- Tokens