JWT from scratch — signing, verifying, and the alg=none attack
Build and verify a JSON Web Token by hand, see the alg=none attack work, and understand why you cannot really log a JWT user out.
JWT from scratch — sign and verify by hand, the alg=none attack, and why you cannot log anyone out
The sentence to own: a JWT is signed, not encrypted, and it is valid until it expires — so anyone can read it, nobody can forge it, and you cannot revoke it, which is the property that decides whether it is the right tool.
You will implement one in twenty lines. Then you will implement the attack that broke a generation of JWT libraries, and the reason it worked will be obvious.
Part 1 — The structure
THREE BASE64URL SEGMENTS, JOINED BY DOTS:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiI0MiIsImV4cCI6MTc1NX0 . 3Zm8...
└──────────── HEADER ──────────────┘ └────────── PAYLOAD ─────────┘ └ SIGNATURE ┘
header {"alg": "HS256", "typ": "JWT"}
payload {"sub": "42", "exp": 1755100000, "role": "admin"} the CLAIMS
sig HMAC-SHA256(base64url(header) + "." + base64url(payload), SECRET)
BASE64 IS NOT ENCRYPTION. IT IS AN ENCODING. Paste any JWT into jwt.io — or
just base64-decode the middle segment — and read every claim.
⇒ THEREFORE: NEVER PUT ANYTHING SECRET IN A JWT. Not an email if you
care, not an internal id you consider sensitive, certainly not a
password hash or a permissions matrix you would rather not publish.
⇒ WHAT THE SIGNATURE BUYS YOU IS INTEGRITY, NOT CONFIDENTIALITY: the
holder cannot CHANGE `"role": "user"` to `"role": "admin"` without
invalidating it. They can always READ it.
⇒ base64URL, not standard base64: `-` and `_` instead of `+` and `/`, and
NO `=` PADDING — a wrong padding implementation is the most common
bug when hand-rolling this.
Part 2 — Sign and verify, by hand
import base64, hmac, hashlib, json, time, secrets
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode().rstrip("=") # strip padding
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) # add it back
def encode(payload: dict, secret: bytes) -> str:
header = {"alg": "HS256", "typ": "JWT"}
h = b64url(json.dumps(header, separators=(",", ":")).encode())
p = b64url(json.dumps(payload, separators=(",", ":")).encode())
signing_input = f"{h}.{p}".encode()
sig = hmac.new(secret, signing_input, hashlib.sha256).digest()
return f"{h}.{p}.{b64url(sig)}"
def decode(token: str, secret: bytes, *, audience: str, issuer: str) -> dict:
try:
h, p, s = token.split(".")
except ValueError:
raise InvalidToken("malformed")
# 1. VERIFY THE SIGNATURE WITH *OUR* ALGORITHM — never the header's (Part 3)
expected = hmac.new(secret, f"{h}.{p}".encode(), hashlib.sha256).digest()
if not hmac.compare_digest(b64url_decode(s), expected): # Day 114
raise InvalidToken("bad signature")
# 2. ONLY NOW parse the payload — signature first, always
claims = json.loads(b64url_decode(p))
now = time.time()
if claims.get("exp", 0) <= now: raise InvalidToken("expired")
if claims.get("nbf", 0) > now + 60: raise InvalidToken("not yet valid") # clock skew
if claims.get("aud") != audience: raise InvalidToken("wrong audience")
if claims.get("iss") != issuer: raise InvalidToken("wrong issuer")
return claims
FOUR RULES IN THAT FUNCTION, EACH OF WHICH IS A REAL CVE IN SOMEBODY'S CODE:
1. VERIFY BEFORE YOU PARSE. Decoding the payload first means you are
acting on attacker-controlled JSON before you know it is authentic —
and libraries that expose `decode(verify=False)` for "debugging" get
used in production every year.
2. CHECK `exp`. A token with no expiry check is a permanent credential.
3. CHECK `aud` AND `iss`. Without `aud`, a token your auth server
issued for SERVICE A is accepted by SERVICE B — so a low-privilege
service can hand its token to a high-privilege one. This is a real and
common cross-service escalation.
4. `compare_digest`, because a signature comparison is a secret comparison.
⇒ allow ~60s of CLOCK SKEW on `nbf`/`exp`, or a slightly fast client
clock rejects perfectly good tokens.
| Claim | Meaning |
|---|---|
sub |
the subject — the user id |
exp |
expiry (unix seconds) — the only revocation you have |
iat / nbf |
issued at / not before |
aud |
who this token is for — check it |
iss |
who issued it — check it |
jti |
a unique id — needed for a denylist (Part 4) |
Part 3 — The alg=none attack
⚠️⚠️ THE VULNERABILITY THAT BROKE MOST JWT LIBRARIES IN 2015, AND IT IS A
DESIGN FLAW IN THE SPEC RATHER THAN A CODING SLIP:
THE VERIFIER READS THE ALGORITHM FROM THE HEADER — AND THE HEADER IS
SUPPLIED BY THE ATTACKER.
1. attacker takes a valid token, edits the payload to `"role": "admin"`
2. sets the header to `{"alg": "none"}`
3. sends it with an EMPTY signature
4. a naive library reads alg=none, concludes "no signature required",
AND ACCEPTS IT.
⇒ TOTAL AUTHENTICATION BYPASS, from a text editor.
⚠️ AND ITS SUBTLER SIBLING — THE HS/RS CONFUSION ATTACK:
your server verifies RS256 with a PUBLIC key, which is public.
⇒ the attacker changes the header to `HS256` and signs the token with
YOUR PUBLIC KEY AS THE HMAC SECRET.
⇒ a library that dispatches on the header calls HMAC-verify with the
key it was given — the public key — AND THE SIGNATURE MATCHES.
⇒ the attacker forged a token using only information you PUBLISHED.
THE FIX FOR BOTH IS ONE PRINCIPLE: **THE VERIFIER DECIDES THE ALGORITHM.**
jwt.decode(token, key, algorithms=["RS256"]) ALWAYS pass this
⇒ note the shape of the lesson, because it generalises far beyond JWT:
NEVER LET UNTRUSTED INPUT SELECT THE CODE PATH THAT VALIDATES IT.
It is the same class of mistake as `pickle` choosing a class to
instantiate (Day 128) and as a parser resolving an ambiguity in the
attacker's favour (Day 104).
Modern libraries require algorithms= and reject none outright. Pass it anyway,
explicitly, every time — and if you ever see verify=False or a missing algorithms= in a review,
that is a blocking comment (Day 100B).
Part 4 — The revocation problem
AN EMPLOYEE IS DISMISSED AT 10:00. THEIR ACCESS TOKEN EXPIRES AT 10:45.
⇒ THEY HAVE FORTY-FIVE MINUTES OF FULL ACCESS, AND THERE IS NOTHING YOU
CAN DO ABOUT IT — because verification is a pure function of the token
and the key. Your server never asks anybody anything.
⇒ THAT IS NOT A BUG. IT IS THE DEFINING PROPERTY: the whole point of a
JWT is that it can be verified WITHOUT A LOOKUP. Revocation requires
a lookup. You cannot have both.
THE THREE HONEST OPTIONS, AND EVERY REAL SYSTEM PICKS ONE:
1. SHORT EXPIRY (5-15 min) + a refresh token ⇒ shrinks the window;
does not close it. This is what most systems do (Day 117).
2. A DENYLIST of `jti`s until their `exp` ⇒ IT WORKS, AND IT MEANS A
LOOKUP ON EVERY REQUEST — at which point you have re-invented
sessions with extra steps and a bigger cookie (Day 115).
3. A per-user `token_version` in the token, compared against the database
⇒ same trade, one row instead of a set.
⇒ SAY THIS OUT LOUD IN AN INTERVIEW, BECAUSE IT IS THE WHOLE POINT:
"STATELESS AUTH" IS STATEFUL AT THE REFRESH TOKEN, AND ANY SCHEME THAT
CAN REVOKE HAS STATE SOMEWHERE. The design question is not "stateless
or not" but "how long am I willing for a revoked credential to keep
working."
Part 5 — Where to keep it, and when to use it
| Storage | Exposure |
|---|---|
⚠ localStorage |
any XSS reads it and exfiltrates it permanently — no HttpOnly possible |
| ⚠ In-memory JS variable | safer, but lost on refresh ⇒ needs a refresh flow anyway |
HttpOnly cookie |
XSS cannot read it — ⚠ CSRF applies, so SameSite + a token (Day 115) |
THE ARGUMENT, AND IT IS THE OPPOSITE OF WHAT MOST TUTORIALS SAY:
"JWT IN localStorage AVOIDS CSRF" IS TRUE AND IS THE WRONG TRADE.
⇒ YOU TRADED A WELL-UNDERSTOOD, EASILY-MITIGATED PROBLEM (CSRF —
SameSite plus a token, both cheap and standard) FOR AN UNMITIGATABLE
ONE (XSS reading your credential and sending it anywhere, forever).
⇒ under XSS an `HttpOnly` cookie still lets the attacker ACT as the user
while the page is open. With localStorage they TAKE the credential
away. That difference is the entire argument.
⇒ SO: HttpOnly + Secure + SameSite=Lax, plus CSRF protection.
WHEN A JWT IS ACTUALLY THE RIGHT TOOL:
✔ SERVICE-TO-SERVICE — service A proves who it is to service B without B
calling the auth server. This is the case JWTs were designed for.
✔ short-lived ACCESS tokens in front of a stateful refresh token (Day 117)
✔ genuinely cross-domain or third-party (OIDC id tokens)
✔ a signed, expiring, single-purpose link — password reset, an invite,
a download URL — where "cannot be revoked" is fine because it is
one-shot and short-lived
✘ A NORMAL WEB APP WITH ONE BACKEND ⇒ use a session (Day 115). You
already have a datastore, and revocation is worth more than the lookup
you saved.
Key rotation uses the kid header: the token names which key signed it, the verifier looks it
up in a keyset. kid is attacker-controlled, so treat it as a lookup key into a fixed set —
never as a filename or a database query fragment (Days 127, 128).
Common mistakes
| Mistake | Correction |
|---|---|
| "The JWT is encrypted" | It is signed. Anyone can read the payload. |
| Putting anything secret in the payload | It is public. Integrity, not confidentiality. |
Trusting alg from the header |
alg=none and HS/RS confusion. The verifier decides. |
| Parsing the payload before verifying | Acting on unauthenticated attacker JSON. |
Not checking exp |
A permanent credential. |
Not checking aud/iss |
Service A's token accepted by service B — cross-service escalation. |
== on the signature |
compare_digest. |
| No clock-skew allowance | A slightly fast client rejects valid tokens. |
| Base64 padding mishandled | urlsafe, strip = when encoding, re-add when decoding. |
Storing it in localStorage |
Traded CSRF (mitigable) for XSS exfiltration (not). |
| Expecting logout to work | It cannot, without a lookup — and then you have a session. |
| Long-lived access tokens | Every minute is a minute a fired employee still has access. |
verify=False anywhere |
A blocking review comment. |
Interview questions
Q: What is a JWT?
Three base64url segments — header, payload, signature — where the signature is an HMAC or an asymmetric signature over the first two. The critical property is that it's signed, not encrypted: anyone can read the claims by base64-decoding the middle segment, and the signature only guarantees nobody has changed them. So it gives you integrity, not confidentiality, and nothing secret should ever go in a payload.
Q: What is the alg=none attack?
The spec has the verifier read the algorithm from the header — and the header is attacker-supplied. So you take a valid token, edit the payload to make yourself an admin, set the header to
{"alg":"none"}, send an empty signature, and a naive library concludes no signature is required and accepts it. Total auth bypass from a text editor. Its sibling is HS/RS confusion: if the server verifies RS256 with a public key, the attacker switches the header to HS256 and signs with that public key as the HMAC secret — forging a token from information you published. The fix for both is one principle: the verifier decides the algorithm and passes it explicitly. And the general form is worth stating — never let untrusted input select the code path that validates it.
Q: How do you log someone out of a JWT-based system?
You can't, and that's the defining property rather than an oversight. Verification is a pure function of the token and the key — the server never asks anyone anything — so a token stays valid until it expires. A dismissed employee keeps access for whatever the remaining lifetime is. The three real options are short expiry with a refresh token, which shrinks the window without closing it; a denylist of
jtis until expiry, which works but means a lookup on every request; or a per-user token version compared against the database, which is the same trade. Once you add any of the last two, you've re-invented sessions with a bigger cookie. So "stateless auth" is stateful at the refresh token, and the real question isn't stateless or not — it's how long you'll tolerate a revoked credential still working.
Q: localStorage or a cookie?
HttpOnlycookie, withSecure,SameSite=Laxand CSRF protection. The usual argument forlocalStorageis that it avoids CSRF, which is true and is the wrong trade: you've swapped a well-understood, cheaply-mitigated problem for an unmitigatable one. Under XSS, anHttpOnlycookie lets the attacker act as the user while the page is open; withlocalStoragethey take the credential away and use it anywhere, forever. That difference is the whole argument.
Q: When would you actually choose a JWT?
Service-to-service, where B can verify A's identity without calling the auth server — that's the case it was designed for. Short-lived access tokens in front of a stateful refresh token. Genuinely cross-domain or third-party flows like OIDC. And single-purpose signed links — a password reset or an invite — where "can't be revoked" is fine because it's one-shot and short-lived. For a normal web app with one backend I'd use a session, because I already have a datastore and revocation is worth more than the lookup I saved.
Mini task
- Implement
encodeanddecodeby hand. Verify againstPyJWTon the same input. - Base64-decode the payload of any JWT you have. Read the claims. Note that you needed no key.
- Get the padding wrong on purpose and observe the failure. Fix it with the
-len(s) % 4trick. - Implement the
alg=noneattack against your own verifier: edit the payload, setalg: none, empty signature. Make it succeed, then fix the verifier. - Implement HS/RS confusion: verify with RS256, then re-sign the token using the public key as an HMAC secret. Make it verify. Then pin the algorithm.
- Remove the
expcheck and confirm a year-old token still works. - Issue a token with
aud: "service-a"and accept it in a verifier expectingservice-b. Then add the check. - Implement a
jtidenylist in Redis with TTL = remaining lifetime. Measure the added latency per request and compare it with a session lookup (Day 115). - Set the clock forward 30 seconds on a client and confirm a valid token is rejected. Add skew tolerance.
- Store a token in
localStorage, then rundocument.cookieandlocalStorage.getItemfrom the console with anHttpOnlycookie alongside. Compare what a script can reach. - Implement
kid-based key rotation with two keys, and rotate without invalidating live tokens.
Exit questions
Answer aloud, no notes.
- What are the three segments, and what does the signature guarantee?
- Why must nothing secret go in a JWT?
- Give the four verification rules, and the failure each prevents.
- What does
audprevent, concretely? - Describe
alg=noneand HS/RS confusion, and the one principle that fixes both. - State the general form of that principle.
- Why can you not log someone out?
- Give the three revocation options and what each costs.
- Why is "stateless auth" a misleading phrase?
localStoragevsHttpOnlycookie — state the trade in one sentence.- Name four cases where a JWT is genuinely right.
- What must you never do with
kid?
Articulation drill
Record two minutes: "Would you use JWTs for your app's authentication?"
Answer with a decision and its criterion: "for a normal web app with one backend, no — I'd use server-side sessions. For service-to-service, or short-lived access tokens in front of a refresh token, yes. The criterion is revocation."
Then the property, stated as a design consequence rather than a complaint: "a JWT is verified as a pure function of the token and the key — the server never asks anyone anything, which is exactly the point and exactly the cost. It's valid until it expires. So when someone is dismissed at ten o'clock and their token expires at quarter to eleven, they have forty-five minutes of access and there's nothing I can do. Short expiry shrinks that window; it doesn't close it. And if I add a denylist to close it, I'm doing a lookup on every request — at which point I've re-invented sessions with a bigger cookie."
Then the attack, because it shows you have looked at the spec rather than the tutorial: "the
thing I'd always check in review is that the algorithm is pinned. The spec has the verifier read alg
from the header, which is attacker-supplied — so alg: none with an empty signature was a total auth
bypass in most libraries, and HS/RS confusion lets someone forge a token using the public key you
published. Both are fixed by the verifier deciding the algorithm. The general form is worth carrying:
never let untrusted input select the code path that validates it."
Close on storage and the trade: "and I'd keep it in an HttpOnly cookie rather than
localStorage. The usual argument is that localStorage avoids CSRF, which is true and is the wrong
trade — CSRF is cheap to mitigate with SameSite and a token, whereas with localStorage any XSS
takes the credential away and uses it anywhere, forever."
Previous: Day 115 · Tomorrow: Day 117 — refresh-token rotation, and detecting a stolen token by its reuse