Web
JSON Web Tokens (JWT)
The session that lives in the token, not the server
A JSON Web Token (JWT) is a signed, self-contained string of three Base64url parts — header.payload.signature — that carries claims so a server can verify a session by checking a signature instead of looking up server-side state.
- Structureheader.payload.signature
- EncodingBase64url (not encrypted)
- Verify costO(1), no DB lookup
- Typical size150 B – 1 KB
- SpecRFC 7519 (2015)
Interactive visualization
Press play, or step through manually. The visualization is yours to drive — try it before reading on.
Watch the 60-second explainer
A condensed visual walkthrough — narrated, captioned, under a minute.
How a JWT replaces server-side sessions
The old way to remember who you are: when you log in, the server generates a random session ID, stores { sessionId → userId, roles, expiry } in a database or Redis, and hands you back just the ID in a cookie. Every subsequent request, the server looks that ID up. The state lives on the server; the cookie is a meaningless 32-byte pointer.
A JWT flips that. Instead of storing state and giving you a pointer, the server packs the state into the token, signs it, and hands you the whole thing. On the next request you send it back. The server doesn't look anything up — it recomputes the signature over the part it received and checks that it matches. If it does, the claims inside are trustworthy because no one without the secret key could have produced that signature. This is what "stateless" means: the server holds no session record at all.
A JWT is three Base64url-encoded segments joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← header
.eyJzdWIiOiIxMjM0Iiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzUwMDAwMDAwfQ ← payload
.dQw4w9WgXcQ-3pZ2k1f7s8tHnY... ← signature
Decode the first two and you get plain JSON:
// header — which algorithm signed this
{ "alg": "HS256", "typ": "JWT" }
// payload — the claims
{ "sub": "1234", "role": "admin", "exp": 1750000000 }
The signature is the only secret-dependent part. For HS256 it is HMAC-SHA256(base64url(header) + "." + base64url(payload), secret). Change a single character of the header or payload and the recomputed HMAC no longer matches — verification fails. That is the whole security model: integrity through a keyed signature, not confidentiality.
The claims: what goes in the payload
The payload is a flat JSON object of claims. RFC 7519 defines seven registered (reserved) claims, all optional, all three letters to keep the token small:
iss— issuer, who minted the token.sub— subject, usually the user ID the token is about.aud— audience, who the token is meant for; a verifier rejects tokens not addressed to it.exp— expiration time as a Unix timestamp; the single most important claim.nbf— not-before; the token is invalid until this time.iat— issued-at, when it was created.jti— JWT ID, a unique identifier you can use to build a revocation denylist.
Everything else — role, email, tenant_id — is a custom (public or private) claim you define. The cardinal rule: the payload is readable by anyone. A signed JWT is encoded, not encrypted. Never put a password, a credit-card number, or anything you wouldn't print on a billboard into it.
When to choose a JWT (and when not to)
- Stateless APIs across many services. A token verified by signature lets a fleet of microservices authorize a request without sharing a session store. With RS256 they only need the public key.
- Short-lived access tokens. A 5–15 minute access token plus a long-lived refresh token is the canonical OAuth 2.0 / OIDC pattern. The short window caps the damage of a leaked token.
- Cross-domain / third-party verification. An identity provider signs once; relying parties anywhere verify with the published public key (the JWKS endpoint).
- One-time signed links. Password-reset and email-confirmation links carry a short-expiry JWT so the link itself proves authorization without a database row.
Reach for an opaque server-side session instead when you need instant revocation (logout that takes effect immediately), when the session is large or changes often, or when it's a single monolith talking to one database — there a session lookup is cheap and revocation is free. JWTs shine precisely when you want to avoid that lookup.
JWT vs server-side sessions vs opaque tokens
| JWT (stateless) | Server-side session | Opaque token + introspection | |
|---|---|---|---|
| Server state per session | None | One row in DB/Redis | One row in token store |
| Verify cost per request | 1 signature check (~microseconds) | 1 store lookup (network/IO) | 1 introspection call (network) |
| Instant revocation / logout | No — valid until exp | Yes — delete the row | Yes — delete the row |
| Size on the wire | 150 B – 1 KB+ | ~32-byte ID | ~32-byte ID |
| Readable by client | Yes (Base64url) | No (opaque) | No (opaque) |
| Cross-service / cross-domain | Easy (share public key) | Needs shared store | Needs shared introspection |
| Scales horizontally | Trivially (no shared state) | Needs sticky sessions or shared store | Needs shared store |
| Best for | Microservices, OAuth access tokens | Monoliths needing logout | Revocable cross-service auth |
The headline trade-off is statelessness for revocability. A session ID is tiny and revocable but requires a lookup on every request. A JWT skips the lookup but can't be un-issued. Most production systems split the difference: a short-lived stateless JWT for the hot path, plus a stateful refresh token for revocation.
What the numbers actually say
- Size: 4× to 30× a session ID. An opaque session ID is ~32 bytes. A minimal HS256 JWT is 150–300 bytes; an RS256 token with roles and a
kidcommonly hits 600–1000+ bytes. That weight rides on every request: a 1 KB token over 100 requests is 100 KB of extra upload versus ~3.2 KB for the session ID. - Verify is fast, but not free. HMAC-SHA256 verification is on the order of a microsecond. RS256 verification (RSA-2048) is roughly 50–100× slower than HMAC — still well under a millisecond, but signing RS256 is the expensive side (often 1–5 ms), which is why you cache keys and verify, not sign, in hot paths.
- The lookup you saved. A Redis session lookup is ~0.2–1 ms over the network; a database lookup can be several ms. At 50,000 requests/second that's the difference between needing a session store sized for 50k QPS and needing nothing at all on the read path.
- Base64url overhead is ~33%. Encoding inflates the raw bytes by 4/3. A 256-bit HMAC signature (32 bytes) becomes 43 Base64url characters; a 2048-bit RSA signature (256 bytes) becomes 342 characters — the single largest part of an RS256 token.
JavaScript implementation
A from-scratch HS256 sign and verify using Node's crypto, so you can see exactly what a library does. Note the Base64url variant (no padding, +/ → -_) and the constant-time compare.
import { createHmac, timingSafeEqual } from 'node:crypto';
const b64url = (buf) =>
Buffer.from(buf).toString('base64')
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const b64urlJson = (obj) => b64url(JSON.stringify(obj));
function sign(payload, secret, { expiresInSec = 900 } = {}) {
const header = { alg: 'HS256', typ: 'JWT' };
const now = Math.floor(Date.now() / 1000);
const claims = { iat: now, exp: now + expiresInSec, ...payload };
const signingInput = `${b64urlJson(header)}.${b64urlJson(claims)}`;
const sig = b64url(createHmac('sha256', secret).update(signingInput).digest());
return `${signingInput}.${sig}`;
}
function verify(token, secret) {
const [h, p, sig] = token.split('.');
if (!h || !p || !sig) throw new Error('malformed token');
// Recompute the signature over the bytes we received.
const expected = b64url(createHmac('sha256', secret).update(`${h}.${p}`).digest());
// Constant-time compare — a fast-fail string === leaks timing.
const a = Buffer.from(sig), b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) throw new Error('bad signature');
const claims = JSON.parse(Buffer.from(p, 'base64url').toString());
if (claims.exp && Math.floor(Date.now() / 1000) >= claims.exp) throw new Error('expired');
return claims;
}
const token = sign({ sub: '1234', role: 'admin' }, process.env.JWT_SECRET);
console.log(verify(token, process.env.JWT_SECRET)); // { iat, exp, sub: '1234', role: 'admin' }
Two details that bite people. First, you verify by recomputing the HMAC over the exact header.payload bytes you received and comparing — you never "decrypt" anything. Second, the comparison must be constant-time; a plain === short-circuits on the first differing byte and leaks how much of a forged signature was correct.
Python implementation
The same HS256 verify in Python with hashlib and hmac, plus the critical pinned-algorithm check that defeats the alg:none attack.
import base64, hashlib, hmac, json, time
def b64url(b: bytes) -> str:
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def b64url_json(obj) -> str:
return b64url(json.dumps(obj, separators=(",", ":")).encode())
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def sign(payload: dict, secret: str, expires_in=900) -> str:
header = {"alg": "HS256", "typ": "JWT"}
now = int(time.time())
claims = {"iat": now, "exp": now + expires_in, **payload}
signing_input = f"{b64url_json(header)}.{b64url_json(claims)}"
sig = hmac.new(secret.encode(), signing_input.encode(), hashlib.sha256).digest()
return f"{signing_input}.{b64url(sig)}"
def verify(token: str, secret: str, expected_alg="HS256") -> dict:
h, p, sig = token.split(".")
header = json.loads(b64url_decode(h))
# NEVER trust the token's own alg — pin it. This blocks the alg:none attack.
if header.get("alg") != expected_alg:
raise ValueError("unexpected algorithm")
expected = hmac.new(secret.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest()
if not hmac.compare_digest(b64url_decode(sig), expected): # constant-time
raise ValueError("bad signature")
claims = json.loads(b64url_decode(p))
if "exp" in claims and time.time() >= claims["exp"]:
raise ValueError("expired")
return claims
tok = sign({"sub": "1234", "role": "admin"}, "super-secret")
print(verify(tok, "super-secret")) # {'iat':..., 'exp':..., 'sub':'1234', 'role':'admin'}
In production you'd use PyJWT or jose, but always pass algorithms=["HS256"] (or ["RS256"]) explicitly. The library will not infer it safely from the token if you leave it open.
Variants worth knowing
JWS vs JWE. What everyone calls "a JWT" is almost always a JWS — JSON Web Signature: signed, readable. JWE is JSON Web Encryption: the payload is actually encrypted and has five parts, not three. Use JWE only when the claims must stay confidential from the client; it's heavier and far less common.
HS256 (symmetric) vs RS256/ES256 (asymmetric). HS256 uses one shared secret to both sign and verify — simple, but every verifier can forge. RS256 (RSA) and ES256 (ECDSA on the P-256 curve) sign with a private key and verify with a public key, so you can distribute verification widely without distributing the power to mint tokens. ES256 produces much smaller signatures (~64 bytes vs RSA's 256) and is the modern default for token size and speed.
Access token vs refresh token. The OAuth 2.0 pattern: a short-lived (5–15 min) JWT access token sent on every request, plus a long-lived, often opaque and stored, refresh token used only to mint new access tokens. This restores revocation — kill the refresh token and the session dies within minutes.
PASETO and Branca. Designed as "JWT without the footguns": no algorithm agility in the header (no alg:none), versioned crypto, encryption by default in some modes. They trade JWT's ubiquity for a smaller, safer surface.
The JWKS endpoint. For RS256/ES256, identity providers publish their public keys as a JSON Web Key Set at a well-known URL. Verifiers fetch and cache it, and select the right key via the kid (key ID) in the token header — which is how key rotation works without redeploying every service.
Common bugs and edge cases
- Putting secrets in the payload. It's Base64url, not encryption. Anyone with the token reads every claim. The signature protects integrity, never confidentiality.
- The
alg:noneattack. Never let the token's header pick the algorithm. Pin the expected algorithm in your verify call and reject anything else, includingnone. - The RS256→HS256 key-confusion attack. If a verifier accepts both and you feed it the RSA public key as an HS256 secret, an attacker who has the public key (it's public!) can forge HMAC-signed tokens. Pin one algorithm.
- Expecting logout to work. A stateless token stays valid until
exp. There is no server record to delete. Use short expiry + refresh tokens, or ajtidenylist, if you need real revocation. - Storing the token in localStorage. Any XSS on the page can read it. Prefer an HttpOnly, Secure, SameSite cookie, paired with CSRF defenses.
- Skipping
exp,aud, andisschecks. Verifying the signature only proves the token wasn't tampered with — not that it's still valid or meant for you. Always validate expiry, audience, and issuer. - Non-constant-time signature comparison. A naive
===leaks timing information about how many leading bytes matched. Use a constant-time compare (timingSafeEqual/hmac.compare_digest).
Frequently asked questions
Is a JWT encrypted?
No. A standard signed JWT (a JWS) is only Base64url-encoded, not encrypted — anyone can paste it into jwt.io and read every claim. The signature proves the payload wasn't altered; it does not hide it. Put nothing secret in the payload. If you need confidentiality, use JWE (JSON Web Encryption) instead.
What is the difference between HS256 and RS256?
HS256 is symmetric: one shared secret both signs and verifies, so every verifier can also forge tokens. RS256 is asymmetric: a private key signs and a public key verifies, so you can hand the public key to dozens of microservices and none of them can mint a valid token. Use HS256 for a single trusted service, RS256 when third parties must verify but not sign.
Why can't you log a JWT user out before the token expires?
Because the token is self-contained — the server keeps no record of it. Once issued, a valid signature is accepted until the exp claim passes, even if the user clicked logout or you deleted their account. Revocation requires bolting state back on: a short expiry plus a refresh token, or a server-side denylist of stolen token IDs.
What is the alg:none attack?
Early JWT libraries let an attacker set the header to {"alg":"none"} and strip the signature; the verifier, trusting the header, accepted any payload unsigned. The fix is to never let the token's own header choose the algorithm — pin the expected algorithm in your verify call and reject everything else.
How big is a JWT and does it slow requests down?
A typical HS256 token with a few claims is 150–300 bytes; an RS256 token with roles and a key ID often runs 600–1000+ bytes. That weight rides on every single request in a header or cookie, so a 1 KB token across 100 requests is 100 KB of extra upload — versus a 32-byte opaque session ID. JWTs trade bandwidth and a signature verification (microseconds) for skipping a database or Redis lookup.
Where should you store a JWT in the browser?
In an HttpOnly, Secure, SameSite cookie, not localStorage. localStorage is readable by any JavaScript on the page, so a single XSS flaw leaks the token. An HttpOnly cookie is invisible to scripts; pair it with SameSite=Strict or Lax and a CSRF token to block cross-site abuse.