In Lesson 1 the stateless credential was a token — usually a JWT, the xxx.yyy.zzz string. Today we crack it open: what each of the three chunks is, what "signed" actually buys you (and what it doesn't), and the two attacks that have owned real systems. By the end you'll have signed, decoded, tampered with, and broken a JWT in Node — and know the one line that stops the break.
A JWT is signed, not secret. Anyone can read the payload (it's just base64); the signature only proves it wasn't changed. So a JWT buys you exactly one thing: tamper-evidence without server-side storage — the server re-checks a signature instead of looking up a record. Every JWT rule, and every JWT attack, follows from that single fact.
A JWT is three Base64URL strings joined by dots: header.payload.signature. The first two are just JSON anyone can decode. The third is the cryptographic seal over the first two.
The payload's fields are called claims. Some are standardized (RFC 7519): sub (subject/user), exp (expiry), iat (issued-at), iss (issuer), aud (audience). The rest are yours to define.
I put a Node script on your machine that signs a token, then decodes, verifies, forges, and attacks it — printing what happens at each step. Deps are installed.
# in your terminal:
cd ~/projects/learn/public/courses/auth/practice/jwt-lab
npm start
It runs five steps. Read them top to bottom — each maps to a section below:
§2 decode the payload with NO secret: {"sub":42,"role":"user","email":"you@example.com","iat":…,"exp":…} → signed ≠ encrypted. Anyone reads it. §4 forge role:admin, keep the old signature: verify REJECTED it → "invalid signature" → THIS is what a JWT buys you: tamper-evidence. §5 attacker sends an UNSIGNED alg:none token: 🛡️ SAFE server rejected it → "jwt signature is required" → because we pinned { algorithms: ["HS256"] }.You just watched the payload be public, watched tampering get caught, and watched the classic bypass get blocked by one option. Now the why.
Step 2 of the lab decoded the payload with Buffer.from(part, 'base64url') — no key, no secret. That's not a flaw, it's the design: a JWT is signed (tamper-evident), not encrypted (confidential). The signature answers "did this come from someone holding the secret, unchanged?" — not "is this hidden?"
// what the lab does — readable to ANYONE holding the token: const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url')); // → { sub: 42, role: 'user', email: 'you@example.com', ... }
jwt.decode() (no verify) is for debugging only. See OWASP JWT Cheat Sheet.In step 4 you changed role:"user" to role:"admin" but kept the old signature — and verify threw "invalid signature". That's the whole value proposition: the server doesn't need a stored record to know the token is genuine, it just recomputes the signature over the (header+payload) it received and checks it matches. No match → forged or altered → rejected. That's how you get "stay logged in" with zero server-side session storage.
Two families of signing algorithm — the choice matters for §05:
| HS256 (symmetric / HMAC) | RS256 / ES256 (asymmetric) | |
|---|---|---|
| Keys | One shared secret signs and verifies | Private key signs, public key verifies |
| Who can verify? | Only holders of the secret | Anyone (the public key is public) |
| Best when | Same app signs & checks (your own login) | One issuer, many verifiers (OIDC, "Sign in with Google") |
| Footgun | Secret leaks = full compromise | Confusing the two = the attack in §05 |
Why does "Sign in with Google" use RS256? Because Google signs with its private key and your server (and everyone else's) verifies with Google's published public key — no shared secret to distribute. You'll meet this directly in the OAuth/OIDC lessons.
Both classic JWT attacks exploit the same mistake: letting the token's own alg header decide how the server verifies it. The token is attacker-controlled — so trusting its alg field is trusting the attacker.
alg: noneThe spec includes a "none" algorithm (no signature). An attacker sends a token with header {"alg":"none"} and an empty signature. A naive verifier reads the header, sees "none," and skips signature checking — so any forged payload is accepted. You saw the lab's safe server reject it with "jwt signature is required".
Subtler and nastier. The server uses RS256 (verifies with a public key — which is, by definition, public). The attacker changes the header to HS256 and signs a forged token using that public key as an HMAC secret. If the server just "verifies with the key it has," it'll HMAC-verify with the public key — which now matches. The attacker forged a valid token using only public information. See PortSwigger — Algorithm confusion.
// ❌ vulnerable — verifier trusts the token's alg header jwt.verify(token, key); // ✅ safe — YOU decide the algorithm; alg:none and HS/RS confusion both die jwt.verify(token, key, { algorithms: ['HS256'] });Always pass
algorithms. Never mix symmetric and asymmetric in the same allowlist (that re-opens Attack B). Modern jsonwebtoken (v9) is safer by default, but pinning is non-negotiable. See WorkOS — Algorithm confusion.Back to Lesson 1's fork. A JWT is a stateless credential, and statelessness is the thing you're really choosing — with all its baggage. The killer drawback: you can't easily revoke one. It's valid until exp, full stop. So logout, bans, and "kill all my sessions" are hard.
Stateless APIs, mobile clients, service-to-service calls, or an issuer that others verify (OIDC). Short-lived access tokens + a refresh mechanism (Lesson 9).
It's a normal first-party web app. You want instant logout/ban and an HttpOnly cookie — the boring safe default from Lessons 1–2. "JWT for browser login" is the classic over-reach.
Authorization: Bearer header, tutorials tell you to stash them in localStorage. That's the XSS-exfiltration hole from Lesson 2. If a token must live in the browser, an HttpOnly cookie is still safer than localStorage. See "Don't use JWTs for sessions".Q1. A teammate base64-decodes a JWT, sees the user's email and role in plain text, and files a "tokens are leaking PII" bug. Are they right?
Partly. Readability is by design — JWTs are signed, not encrypted (§03). It's not a "leak" in the crypto sense. But the real lesson holds: don't put anything you wouldn't show the user in a JWT payload. If that email/role is sensitive, it shouldn't be there. So: not a signature bug, but a valid data-minimization concern.
Q2. Why does pinning { algorithms: ['RS256'] } stop the RS256→HS256 confusion attack?
The attack works by getting the server to verify an HS256 token using the RSA public key as an HMAC secret. If you pin algorithms:['RS256'], the server refuses to treat the token as HS256 at all — it only ever does RSA verification, for which the attacker would need the private key. The token's own alg header is ignored. (§05.)
Q3. You ship JWTs for browser login. A user clicks "log out" but their token still works for 15 minutes. Why, and what's the usual fix?
JWTs are stateless — there's no server record to delete, so the token stays valid until exp (§06). Usual fixes: keep access tokens short-lived (minutes) and pair them with a revocable refresh token, or maintain a server-side denylist of revoked tokens (which re-adds the state you were avoiding). This is exactly the trade-off from Lesson 1, and the subject of Lesson 9.
You've now got both halves of "stay logged in": stateful sessions (L1–2) and stateless JWTs (L3). Next we step back to phase ① — the login event — starting with the unglamorous-but-critical passwords done right: why you hash and never encrypt them, what bcrypt/argon2 actually do, and the handful of lines that separate a safe password store from a breach headline. Then we climb into OAuth2.
verify with a pinned algorithms allowlist, and remember you can't easily revoke one.💬 Ask me anything — e.g. "show the RS256 version with a key pair", "what does a refresh token look like?", or "decode the JWT my real app uses and tell me what's in it" (paste it — but scrub it after, it's a live credential). · Back to Lesson 2 · Lesson 1 · Resources.