JWT vs Session Cookies: How They Differ and When to Use Each
A session cookie is a key to state kept on the server; a JWT carries signed state to the server. That one difference drives every trade-off.
The comparison is usually framed as JWT versus cookies, but those are not the same kind of thing. A cookie is a way of sending data with every request. A JWT is a data format. The real choice is between a server-side session, where the browser holds a random ID and the server looks up who it belongs to, and a self-contained token, where the client holds signed claims that the server can check without a lookup. Once that is clear, the questions about revocation, storage and CSRF fall into place.
How a server-side session works
After a successful login, the server creates a session record (user ID, roles, expiry) in memory, a database or a store such as Redis, and sends the browser an unguessable ID in a cookie. The browser attaches the cookie to every later request to that site, and the server loads the record for each one. The ID means nothing on its own; 32 random bytes encode to 43 characters.
Set-Cookie: __Host-session=p-sLYX7J3ziMKxq-dadVMuQJNSVBHM5hrw7MJPihGQ0; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=86400
The __Host- prefix makes browsers reject the cookie unless it is Secure, has Path=/ and sets no Domain, so a subdomain cannot overwrite it.
How a JWT works
A JSON Web Token (RFC 7519) is three Base64URL-encoded parts joined by dots: a header naming the algorithm, a payload of claims such as sub and exp, and a signature over the first two. The server checks the signature and the expiry with a key and trusts the claims without asking a database. It is usually sent in an Authorization: Bearer header.
The payload is only encoded, not encrypted. Anyone holding the token can read it, which you can see by pasting one into the JWT Decoder. Never put secrets or personal data in a JWT you would not show the user. Encrypted tokens exist (JWE, RFC 7516), but most JWTs in use are signed only. For why the parts use - and _ instead of + and /, see Base64 vs Base64URL.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzg0MTIiLCJpYXQiOjE3NjAwMDAwMDAsImV4cCI6MTc2MDAwMDkwMH0.08BFm85KQIp34VSetcibqwBVLpHKR3IZhI_IPNeTif4
// Reading the payload needs no key at all (browser):
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const bytes = Uint8Array.from(atob(part), c => c.charCodeAt(0));
JSON.parse(new TextDecoder().decode(bytes));
// { sub: 'user_8412', iat: 1760000000, exp: 1760000900 }
The trade-offs side by side
| Server-side session | JWT | |
|---|---|---|
| Client holds | A random, meaningless ID | Signed, readable claims |
| Server needs | A session store, shared by every server | Only the key to verify signatures |
| Logging a user out everywhere | Delete the session: immediate | Not possible before exp without extra state |
| Role or permission change | Applies on the next request | Applies when a new token is issued |
| Typical transport | Cookie, sent automatically | Authorization header, or a cookie |
| Size per request | 43-character ID in our example | 152 to 540 characters in our examples |
Revocation, expiry and refresh tokens
Statelessness is the main selling point of JWTs and also their main weakness. A valid token stays valid until it expires, even after the user logs out, changes their password or is banned. The usual answer is to keep access tokens short-lived, often minutes rather than hours, and pair them with a longer-lived refresh token that the client exchanges for new access tokens. The refresh token is checked against server-side state, so revoking it stops new access tokens from being issued.
The OAuth 2.0 Security Best Current Practice (RFC 9700) describes refresh token rotation: every refresh returns a new refresh token and invalidates the old one, so a stolen token that gets reused can be detected. If you need instant revocation of access tokens as well, you end up keeping a deny list of token IDs (jti) or a per-user token version, which is server-side state again. At that point, check whether a plain session would be simpler.
Where to store the token: cookies, localStorage and XSS
Anything in localStorage or sessionStorage can be read by every script running on your page. One cross-site scripting (XSS) bug or one compromised third-party script is enough to copy the token and use it from anywhere until it expires.
A cookie marked HttpOnly cannot be read by JavaScript at all, so XSS cannot steal it. XSS can still send requests from the victim's open page, and the browser will attach the cookie, so HttpOnly limits the damage rather than preventing it. Preventing XSS remains essential either way; HttpOnly cookies simply make a successful attack less useful to the attacker.
CSRF and SameSite
The cookie's convenience is also its risk: the browser attaches it automatically, including to requests triggered by another website. That is cross-site request forgery (CSRF). Tokens sent in an Authorization header are not exposed to classic CSRF, because the browser does not attach a bearer token by itself; your own code has to add it.
SameSite=Strict: the cookie is never sent on requests started from another site, including following a link to yours.SameSite=Lax: sent when the user navigates to your site with a normal link (a top-level GET), but not on cross-site form posts, fetches or embedded resources. Chrome applies Lax to cookies that do not set the attribute; set it explicitly rather than rely on that.SameSite=None: sent everywhere, and it must be combined withSecure(Chrome rejects it otherwise). Needed only for genuine cross-site use such as embedded widgets.
"Same site" means the same registrable domain, so app.example.com and forum.example.com count as the same site. SameSite does not protect you from a compromised subdomain. For state-changing requests, keep a CSRF token or check the Origin header as well.
Token size
A session ID stays tiny no matter how much you know about the user. A JWT grows with every claim and with the signature algorithm. The tokens we generated for this guide were 152 characters for HS256 with three claims, 241 characters after adding a name, email address and role, and 540 characters for the same claims signed with RS256 and a 2048-bit key. RFC 6265 only requires browsers to support cookies of at least 4,096 bytes, including name and attributes, so a JWT packed with permissions can outgrow a cookie. That size is also sent with every single request.
Mistakes that break JWT security
- Trusting the
algheader. Pin the algorithms your server accepts. Acceptingnone, or letting a token switch from RS256 to HS256 so that the public key is used as an HMAC secret, lets attackers forge tokens. - Decoding instead of verifying. Reading the payload proves nothing; verify the signature with a maintained library first.
- Skipping claim checks. Validate
exp,nbf,issandaud, so a token issued for one service is not accepted by another.
RFC 8725, the JWT Best Current Practices, covers these and more.
When each one fits
| Situation | Usually the better fit |
|---|---|
| Server-rendered web app on one domain | Session cookie |
| Single-page app served from the same site as its API | Session cookie, or a backend-for-frontend that keeps tokens on the server |
| Native mobile app | Short-lived access token plus refresh token in the platform's secure storage |
| Microservices verifying requests without calling an auth service | JWT access tokens signed with an asymmetric key such as RS256 or ES256 |
| Third-party access to your API | OAuth 2.0 access tokens, which are often JWTs |
The hybrid: a JWT inside an HttpOnly cookie
Many apps put the JWT itself in an HttpOnly; Secure; SameSite cookie. Scripts cannot read it, and servers can still verify it without a session lookup. The trade-offs come along too: the cookie is sent automatically, so CSRF defences are needed again; the 4 KB cookie budget applies; and revocation is exactly as hard as with any other JWT. It is a sound design for a browser app backed by stateless APIs, as long as you treat it as a cookie for CSRF purposes and as a JWT for revocation purposes.