The Browser Contract
Sessions vs JWT: Stateful vs Stateless Login
What a signed JWT is, how it is different from a plain session ID, and where “stateless” stops helping: logging out, expiry, refresh tokens and the claims you must check.
On this page
In one line: a session is a ticket number that the server looks up in its own records. A JWT (JSON Web Token) is a signed note that the client carries, and the server can check it without looking anything up. "Stateless" (the server keeps no records) helps you scale, but it makes instant logout hard.
Both are ways to remember who is logged in. This is called authentication (proving who you are). A server must remember it because HTTP is stateless: each request arrives on its own, with no memory of the one before. "Sessions or JWT?" is rarely about which one is better. The real questions are: who holds the data, how fast you must be able to say "this user is logged out now", and what happens when a credential leaks. This lesson gives you the words and a simple rule to decide.
The two models
Server-side session (stateful)
A session is a record, kept on the server, that says "this visitor is logged in as user 42". "Stateful" means the server remembers things about each user. Think of a cloakroom: you hand in your coat and get a numbered ticket. The ticket is just a number. The coat stays in the cloakroom.
- The user logs in with a password.
- The server creates a session record (user ID, roles, created time, expiry time). It keeps the record in a store, such as Redis (a very fast in-memory database) or a database table.
- The server sends back a random, unguessable session ID in a cookie (a small piece of text that the browser saves and sends back on every later request).
- On every request, the browser sends the cookie. The server looks up the record and knows who the user is.
Browser ──Cookie: sid=9f3a…──▶ Server ──lookup sid──▶ Redis { userId: 42, role: "admin" }The cookie holds no information about the user. It is only a key. If you delete the record, the key stops working at once.
JSON Web Token (stateless)
A JSON Web Token (JWT, said "jot") is a compact piece of text that holds a few facts about the user, such as the user ID, and is signed by the server. A signature is a value made from the data and a secret key. If anyone changes the data, the signature no longer matches. "Stateless" means the server keeps no record. The token itself carries the information. Think of a signed concert wristband: the guard can see it is real just by looking at it. The guard does not need to call the box office.
- The user logs in.
- The server builds a claims object (
sub,exp,roleand so on). A claim is one named fact in the token, such as the user ID. The server signs the object with a secret or a private key (explained below), and sends back the token. - The client sends the token on each request (in a header or a cookie). A common way is the
Authorization: Bearer <token>header. "Bearer" means "whoever holds (bears) this token is allowed in". - The server checks the signature and the claims. It does not need a database lookup. The token itself carries the identity.
Browser ──Authorization: Bearer eyJhbGciOi…──▶ Server ──verify signature──▶ { sub: 42, role: "admin" }What a JWT actually is
A JWT is three base64url strings joined by dots. (Base64url is a way to write any data using only letters, digits, - and _. It is only an encoding, not encryption, so it hides nothing.)
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiI0MiIsInJvbGUiOiJhZG1pbiIsImlzcyI6Imh0dHBzOi8vYXV0aC5leGFtcGxlLmNvbSIsImF1ZCI6Im15LWFwaSIsImlhdCI6MTc0OTk5NjQwMCwiZXhwIjoxNzUwMDAwMDAwfQ . <signature> (part 1: header) (part 2: payload, the claims) (part 3: signature)The header says which algorithm signed the token. The payload holds the claims. The signature is the proof. Decoded:
{ "alg": "RS256", "typ": "JWT" }{ "sub": "42", "role": "admin", "iss": "https://auth.example.com", "aud": "my-api", "iat": 1749996400, "exp": 1750000000 }A signed JWT is not encrypted. Anyone who has it can base64-decode it and read the payload. The signature only proves two things: the token was not changed, and who issued it (the holder of the signing key). Never put secrets, passwords or sensitive personal data in a standard (JWS) token. Encryption means scrambling data so only the key holder can read it. Encrypted tokens (JWE, JSON Web Encryption) exist, but most apps use signed ones (JWS, JSON Web Signature).
Registered claims you must understand
Registered claims are claim names that the JWT standard (RFC 7519) defines, so every library understands them. Unix seconds means the number of seconds since 1 January 1970 UTC.
| Claim | Meaning | Check that… |
|---|---|---|
iss | Issuer: who created the token | It is the issuer you expect |
sub | Subject: the user | It matches a real, active user |
aud | Audience: who the token is for | Your API is in the list (this stops a token for service A from working on service B) |
exp | Expiry time (Unix seconds) | It is in the future (allow a small difference between clocks, called clock skew) |
nbf | Not valid before this time | That time has arrived |
iat | Issued at | The time looks reasonable, if you limit the maximum age |
jti | A unique token ID | It is not on a deny list, if you keep one |
Signing algorithms
A signing algorithm is the math recipe used to make and check the signature. A key is a secret value (or one half of a key pair) that the recipe needs. The two kinds differ in one line: a symmetric key is one secret that both signs and checks; an asymmetric pair has a private key that signs and a public key that checks.
| Type | Algorithms | Who can verify | Typical use |
|---|---|---|---|
| Symmetric (one shared secret) | HS256 | Anyone with the secret. They can also forge tokens | One service that signs and checks its own tokens |
| Asymmetric (a pair of keys) | RS256, ES256, EdDSA | Anyone with the public key. Only the holder of the private key can sign | One auth service issues tokens and many services check them (the public keys are published at a JWKS endpoint: a URL that returns a JSON Web Key Set, a list of public keys) |
JWT attacks interviewers expect you to name
alg: none: the header can say"alg": "none", which means "no signature". Some simple libraries accepted such tokens. Always set the allowed algorithms when you verify.- Algorithm confusion: RS256 uses a key pair and HS256 uses one shared secret. An attacker changes
RS256toHS256and signs with the public key (which everyone can get). A library that trusts the algorithm in the token header will accept it. The fix is the same: fix the expected algorithm for each key. - Weak HS256 secrets can be guessed offline by trying many values (this is called brute force). Use long random secrets (32 or more random bytes) or asymmetric keys.
- Using
decodeinstead ofverify. Decoding only reads the payload. It does not check the signature. Verifying checks the signature and the claims. - Not checking
exp,audandiss. A valid signature for the wrong audience is still wrong. kidheader injection:kid(key ID) is a header that says which key signed the token. If you use it to pick a file or run a database query, an attacker can put harmful text in it. Treat it as untrusted input.
// Verify with explicit expectations (jose library)import { jwtVerify, createRemoteJWKSet } from "jose";const jwks = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));export async function verifyAccessToken(token: string) { const { payload } = await jwtVerify(token, jwks, { issuer: "https://auth.example.com", audience: "my-api", algorithms: ["RS256"], // pin it }); return payload; // exp is checked automatically}Head-to-head comparison
| Server session | JWT | |
|---|---|---|
| Where the data lives | On the server (Redis or a database) | In the token the client holds |
| Cost per request | A lookup in the store | A signature check (uses CPU, no I/O, meaning no network or disk call) |
| Log out or revoke now | Easy: delete the record | Hard: the token is valid until exp, unless you add a deny list |
| Role or permission change | Works on the next request | Old values stay until the token expires or is refreshed |
| Many servers | Needs a shared store (Redis) or sticky sessions (a load balancer rule that always sends the same user to the same server) | Any server can check the token with the key |
| Size | A tiny ID | Hundreds of bytes up to kilobytes, sent on every request |
| Use across services or by third parties | Awkward (everyone needs access to the store) | Natural: pass the token and check it with the public key |
| If a credential leaks | Revoke the session | Valid until it expires, so keep it short-lived |
| Typical fit | First-party web apps | APIs, service-to-service calls, mobile, third-party access |
Making JWTs workable: short access tokens plus refresh tokens
You cannot easily take a JWT back. So you make it expire quickly. Then you give the user a second, longer-lived credential that can get new tokens. A credential is anything that proves who you are. An access token is the short-lived token that your API accepts. A refresh token is the long-lived token that is used only to get a new access token.
| Access token | Refresh token | |
|---|---|---|
| Lifetime | Minutes (5 to 15) | Days to weeks |
| Sent to | Your API on every request | Only the auth endpoint |
| Stored | In memory, or in a cookie | In an HttpOnly cookie, and recorded on the server |
| Can be revoked | Not one by one (it just expires) | Yes (delete or flag the record) |
Refresh token rotation: each time a refresh token is used, you issue a new one and cancel the old one. If someone shows the old token again, it was stolen. Then you revoke the whole token family and force a new login. A quiet theft now becomes something you can detect.
Login ─▶ access (10 min) + refresh R1...Access expires ─▶ send R1 ─▶ new access + refresh R2 (R1 now invalid)Attacker replays R1 ─▶ reuse detected ─▶ revoke R2 and the family ─▶ everyone re-logs inNotice what this really is. Rotation means replacing a secret with a new one on a schedule or on every use. The refresh token works like a server-side session. Many "stateless" systems quietly bring state back exactly where they need control.
How to log a user out when you use JWTs
You cannot "delete" a token that is already in someone's hand. These are the practical options:
- Delete it on the client and revoke the refresh token on the server. The access token still works until its short expiry. This is often fine.
- Deny list (a list of token IDs that are no longer accepted) by
jtiin Redis, with a TTL (time to live, the time after which the entry deletes itself) equal to the token's remaining life. Now you do a lookup on every request. That is the state you wanted to avoid. - Token version: store a
tokenVersionon the user. Put it in the token. When you verify, compare it with the stored value. Adding 1 to it makes every token of that user invalid. This needs one cached lookup. - Use sessions for that part of the system.
Choosing: a decision rule
- First-party web app (Next.js with its own backend), one domain family, and you need instant logout and permission changes: use server sessions in an HttpOnly cookie. This is simpler and safer by default. Libraries such as Auth.js (a login library for Next.js, used in its database-session mode) or iron-session (a library that keeps the session in an encrypted cookie) fit here.
- Public API, mobile clients, third-party integrations, or microservices that check a token from a central auth service: use short-lived JWT access tokens (signed with asymmetric keys) plus rotating refresh tokens. A microservice is one small service in a system made of many small services.
- Both: a common pattern is a session cookie in the browser. It talks to a backend-for-frontend (a server you own that sits between the browser and other APIs). This server holds the tokens and calls other APIs for the user (Lesson 03).
"JWT" and "session" are not opposites in a cookie. You can store a JWT inside an HttpOnly cookie. A session ID is also "just a cookie". The real questions are who holds the state and how revocation works.
Common mistakes
- Choosing JWT because it sounds "modern" or "scalable" for a single-domain web app. Sessions would be simpler and safer.
- Long-lived access tokens (days or weeks) with no way to revoke them.
- Putting sensitive data in the payload. People forget that anyone can read it.
- Skipping
audandisschecks, or accepting anyalgthat the token names. - Storing the token in
localStorage(Lesson 03). - Putting roles in the token and trusting them for too long. A demoted admin keeps admin powers until the token expires.
- Sequential or guessable session IDs. Session IDs must come from a cryptographically secure random generator with plenty of entropy (randomness). OWASP (the Open Worldwide Application Security Project) recommends at least 64 bits.
Interview questions
How do you log out a user with a JWT?
Delete the token on the client. Revoke the refresh token on the server, so no new access tokens can be made. The access token then dies at its short exp. If you need to cancel it at once, keep a deny list of jti values (or a token version for each user) and check it on every request. This brings back some state, and you should say so.
Is a JWT encrypted?
No. A standard signed JWT (JWS) is only encoded and signed. Anyone can read the payload by base64-decoding it. The signature only proves that the token is unchanged and who made it. Do not put secrets in it. Use JWE (an encrypted JWT) if you need privacy. Or keep sensitive data on the server and put only an ID in the token.
Which claims must you validate?
Check exp (not expired), iss (the issuer you expect), aud (made for your API), and nbf if it is present. Also check the signature itself, with a fixed algorithm. You can also check that sub matches an active user and that jti is not revoked.
Scenario: a token leaks. What limits the damage?
Several things help. Use a short access-token lifetime. Use refresh-token rotation with reuse detection. Be able to revoke refresh tokens or raise a user's token version. Use narrow scopes (a scope is a limit on what the token may do, such as read:orders) and audiences, so the token only works for what it was made for. Watch for strange use. If the signing key itself leaked, rotate (replace) the key.
When would you pick sessions for a Next.js app?
Pick sessions when it is a first-party app on your own domain, and the browser talks to your own backend. They are also good when you want simple, instant logout and permission changes. Use an HttpOnly, Secure, SameSite cookie that holds a random session ID. Back it with Redis or a database. This is hard to misuse, and you do not handle tokens in client code.
What is the difference between HS256 and RS256, and why does it matter?
HS256 uses one shared secret to sign and to verify. So every service that can verify can also forge tokens. RS256 uses a private key to sign and a public key to verify. So many services can verify tokens without being able to make them. For systems with many services, prefer asymmetric signing and publish the public keys at a JWKS endpoint.
What is refresh-token rotation?
Each time a refresh token is used, the server issues a new one and cancels the old one. If a refresh token that was already used shows up again, it means theft (or a replay). Then the server revokes that token family and forces a new login. This makes stolen refresh tokens easy to detect and limits how long they work.
Why is "stateless" often a half-truth?
Some jobs need records on the server: logging out, revoking, rotating refresh tokens, blocking hacked accounts, or changing permissions fast. These records are refresh-token tables, deny lists and token versions. Real systems are stateless for the hot path (checking access tokens). They are stateful for control (refresh and revocation).
Scenario: the product wants a "log out of all devices" button, but auth is JWT-only. How do you build it?
Pure stateless tokens cannot be revoked, so you add a little state. Keep a tokenVersion for each user (or a session table) and put the version in the token. "Log out everywhere" adds 1 to the version. Then every older token fails verification the next time it is used. Also revoke refresh tokens on the server, and keep access tokens short-lived so the leftover time is small.
Key takeaways
- A session is an opaque key (it means nothing by itself) to data on the server. A JWT is signed data that the client carries.
- JWTs are readable and not encrypted. Check the signature,
exp,issandaud, with a fixed algorithm. - The price of stateless checking is hard revocation. Reduce the pain with short access tokens, rotating refresh tokens and token versions.
- For a first-party web app, sessions are usually the simpler and safer default. JWTs work well across services and for third-party access.
What's next
Lesson 03: Where Do Tokens Live? Now you know what the credential is. Next: where the browser should keep it, and how that choice trades XSS risk against CSRF risk.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Passwords and Login: Hashing, Sessions, MFA and Rate Limits · Security for Developers
- Authentication and Authorization in Next.js · Next.js App Router
- API Authentication and Authorization: API Keys, OAuth 2.0 and Scopes · API Design
Part of the study guide
- Full-Stack Developer Interview Guide: Questions and Answers
- Web Security Guide for Developers: OWASP Top 10 Basics
Related series