Log a user out of a session-based app and the server forgets them on the next request. Log them out of a JWT-based app and, unless you built extra machinery for it, the token they’re holding stays valid until it expires. That difference is the real decision behind “JWT vs session”. The rest follows from it.
What a session actually stores
A session keeps the user’s state on the server and hands the browser only a lookup key.
- The user logs in with a password.
- The server creates a session record (user ID, roles, whatever it needs) and stores it in memory, a database, or Redis, keyed by a random session ID.
- The server sends that ID to the browser as a cookie, marked
HttpOnlyso client-side JavaScript can’t read it. - On every request, the browser sends the cookie back automatically. The server looks up the ID in the store and gets the session data.
The cookie carries no user information, only an opaque string the server resolves on each request. Delete the record in the store and the session is gone on the next request, on every device.
What’s inside a JWT
A JSON Web Token skips the lookup by putting the state in the token itself, signed so it can’t be tampered with.
A JWT is three base64url-encoded parts joined by dots: header.payload.signature. The payload is a JSON object of claims. RFC 7519 defines a short list of registered ones:
| Claim | Name | Meaning |
|---|---|---|
iss | Issuer | Who issued the token |
sub | Subject | Who the token is about |
aud | Audience | Who the token is meant for |
exp | Expiration | Reject the token after this time |
nbf | Not before | Reject the token before this time |
iat | Issued at | When the token was created |
jti | JWT ID | Unique ID, useful for revocation and replay checks |
The server signs the header and payload with a secret (HMAC) or a private key (RSA/ECDSA) and sends the result to the client. Any server holding the matching key, or only the public key for asymmetric signing, can verify the signature and trust the claims. No database round trip, no shared session store. In a system with several services, each one can check a token on its own.
The flip side: anyone holding the token can read the payload. Base64 is an encoding, not encryption. Never put a password, a secret, or personal data you wouldn’t print in a log into a JWT payload.
How to set up session authentication in Express
A typical Node setup uses express-session for the middleware and connect-redis so sessions survive a server restart and work across multiple instances. If Redis is new to you, how Redis caching works covers the basics of running it.
| |
| |
req.session.destroy() removes the Redis entry. The next request with that cookie finds nothing and is treated as logged out. No token blacklist, nothing to retrofit later.
How to set up JWT authentication in Node
The same flow with jsonwebtoken, issuing a short-lived access token and a separate refresh token:
| |
| |
jwt.verify checks the signature and the exp/nbf claims in one call and throws if either fails. That is the entire authentication check, with no store involved.
The algorithms: ["HS256"] option matters. Without it, a token signed with a different algorithm than you intended could still pass, which is how several JWT libraries ended up with “alg confusion” vulnerabilities. Hardcode the accepted list on verify and reject alg: "none" outright.
The refresh token is what makes a 15-minute access token livable. The client keeps it, and when the access token expires, trades it for a new pair. Store the refresh token’s jti server-side so you can revoke that one chain without touching access tokens already in flight.
Why JWTs are hard to revoke
A session is a record in a store: delete it and the user is logged out everywhere. A JWT is a self-contained claim that any verifier accepts until exp says otherwise. Logging out, banning a user, or downgrading their role does nothing to a token already issued, because the verifying server has no record of it to delete.
The usual mitigations:
- Short access-token lifetimes. 15-30 minutes is the common range, so a stolen or stale token has a small window.
- Refresh token rotation. Each refresh issues a new refresh token and invalidates the old one; reusing an old refresh token revokes the whole chain. OAuth 2.1 expects this (or sender-constrained tokens) for public clients.
- A narrowly scoped denylist. To kill a specific token before it expires, key the denylist on
jtiplusiss, not the raw token string. The OWASP JWT Cheat Sheet explains why denylisting the string itself is unreliable. A denylist checked on every request also puts a database lookup back on every request, which is the cost JWTs were supposed to remove.
If your app ends up doing a lookup on every verification anyway, a session would have been the simpler design.
Where to store a JWT in the browser
For a browser app, put the token in an HttpOnly, Secure cookie, not localStorage. Any script running on the page can read localStorage, so one XSS hole hands over every token in it. The cost: once the token rides in a cookie, the browser sends it automatically, and you are back to defending against CSRF with SameSite and CSRF tokens, exactly as with sessions. The full comparison is in LocalStorage vs SessionStorage vs Cookies.
When to use sessions and when to use JWTs
| Situation | Use |
|---|---|
| One server-rendered app, one backend | Sessions: simpler, trivial to revoke |
| Instant logout or instant permission changes | Sessions, or JWTs with a jti denylist |
| Several backend services that each verify the user independently | JWTs: no shared session store to call back to |
| Mobile app or third-party API clients | JWTs: bearer tokens fit a stateless HTTP API |
Neither is more secure in the abstract. A Redis-backed session and a 15-minute JWT with rotated refresh tokens both hold up. The difference is architectural: how many services need to trust the claim, and how fast you need to take it back.
Building one app with one backend? Default to sessions and get revocation for free. Switch to JWTs the day a second service needs to know who the user is without calling back to the service that logged them in.