auth · Lesson 3 · Node + Express

JWT Anatomy

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.

🎯 Mission: a mental model of modern auth + the ability to wire it into a real app
The one idea

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.

01 The three chunks

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.

HEADER { alg: "HS256", typ: "JWT" } PAYLOAD (claims) { sub: 42, role, exp… } SIGNATURE HMAC/RSA over the first two . . 👁 READABLE by anyone — base64, no key needed 🔏 needs the secret to MAKE Signature = f( base64(header) + "." + base64(payload), secret key ) Change any byte of header or payload → signature no longer matches → rejected
Header and payload are public JSON. The signature is the only part that needs the key — and it's computed over the other two, which is what makes tampering detectable.

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.

02 🎯 Your tangible win (4 min): break a JWT

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:

🎯 The win — three of those outputs, verbatim from your machine
§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.

03 "Signed, not secret" — the rule everything hangs on

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', ... }
⚠️ Direct consequences Never put secrets in a JWT (passwords, full PII, internal flags you don't want users seeing) — the payload is world-readable. And a JWT you receive is only trustworthy after you verify its signature; jwt.decode() (no verify) is for debugging only. See OWASP JWT Cheat Sheet.

04 What signing actually does (and the two algorithm families)

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)
KeysOne shared secret signs and verifiesPrivate key signs, public key verifies
Who can verify?Only holders of the secretAnyone (the public key is public)
Best whenSame app signs & checks (your own login)One issuer, many verifiers (OIDC, "Sign in with Google")
FootgunSecret leaks = full compromiseConfusing 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.

05 The two attacks — and the one-line defense

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.

Attack A — alg: none

The 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".

Attack B — RS256 → HS256 confusion

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.

🛡️ The defense is one option: pin the algorithm allowlist
// ❌ 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.

06 So… should you even use a JWT? (the honest take)

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.

✅ Reach for a JWT when…

Stateless APIs, mobile clients, service-to-service calls, or an issuer that others verify (OIDC). Short-lived access tokens + a refresh mechanism (Lesson 9).

⚠️ Prefer a session id when…

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.

⚠️ The localStorage trap (again) Because JWTs often ride in an 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".

07 Check yourself

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?

Show answer

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?

Show answer

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?

Show answer

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.

08 Where we go next

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.

📌 Remember this one line A JWT is signed, not secret: the payload is public, the signature only proves it wasn't tampered with — so never store secrets in it, always 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.