The problem: HTTP doesn't remember
Every HTTP request is independent: the server doesn't know, on its own, that "this request is from the same person who logged in a minute ago". We need a credential that the client presents on every call. There are two big families.
Option A: sessions with cookies
- The user logs in. The backend creates a session on its side (in memory, Redis or a database) and generates an opaque session id.
- That id travels to the browser inside a cookie. From then on, the browser attaches it automatically on every request to the same domain.
- The backend receives the cookie, looks up the session by its id and knows who you are.
The state lives on the server; the cookie only stores a pointer to it.
Option B: tokens (JWT)
- After login, the backend signs a token (a JWT) that already contains the user's data and returns it in the response body.
- The frontend stores it (typically in
localStorage) and attaches it manually on every request, in theAuthorization: Bearer <token>header. - The backend verifies the token's signature and trusts its content without querying any store: the state travels in the token itself (it's stateless).
Where it is stored and what risks it has
| Store | Who attaches it | Main risk |
|---|---|---|
httpOnly cookie |
The browser, alone | CSRF |
localStorage |
Your JS code | XSS |
- A cookie marked
httpOnlyis not accessible from JavaScript: even if there's an XSS flaw (code injection), the attacker can't read the token. In exchange, since the browser sends it on its own, it's vulnerable to CSRF (a malicious site triggering requests to your API on your behalf). localStorageis accessible from JavaScript: if there's XSS, the attacker reads the token and impersonates you. It doesn't suffer CSRF (because you must attach it manually), but theft via XSS is serious.
Anatomy of a JWT
A JWT is three parts separated by dots, each one in base64url:
eyJhbG... . eyJzdWIiOiJhbmEi... . 3rXc8f...
header payload (data) signature
- Header: the signing algorithm (e.g.
HS256). - Payload: the claims (data):
sub(subject),exp(expiry), role... - Signature: guarantees the token has not been altered.
⚠️ The payload is encoded, not encrypted: anyone can read it. The signature prevents it from being modified, but never put secrets inside.
Examples
Read a JWT's payload without verifying the signature
const jwt = "header." + btoa(JSON.stringify({ sub: "ana", role: "admin" })) + ".signature";
const [, payloadB64] = jwt.split(".");
console.log(JSON.parse(atob(payloadB64)));