The setup
I shipped a login flow with hand-rolled JWTs because it was faster than reaching for a library. Then I put on the other hat and tried to break it before anyone else could.
The token check looked fine at a glance:
1export function verify(token: string) {
2 const [head, body, sig] = token.split(".");
3 // trusting the header's alg — the bug
4 const alg = JSON.parse(atob(head)).alg;
5 return checkSignature(alg, `${head}.${body}`, sig);
6}
7
8Reading the algorithm from the attacker-controlled header is the classic alg confusion bug.
9
10What I found
11
12- alg: none — drop the signature entirely and some verifiers wave it through.
13- HS/RS confusion — sign with the public key as an HMAC secret.
14- No expiry check — tokens lived forever.
15
16@clip src="" duration="0:07" caption="Forging an admin token against the local server — access granted in one request."
17
18The proof, mid-forge:
19
20Burp showing a tampered token returning 200 on the admin route. (*)
21
22The fix
23
24Pin the algorithm server-side, verify expiry, and stop trusting the header:
25
26import { jwtVerify } from "jose";
27
28export async function verify(token: string) {
29 const { payload } = await jwtVerify(token, KEY, { algorithms: ["HS256"] });
30 return payload; // throws on bad sig, wrong alg, or expiry
31}
32
33▎ The lesson isn't "use jose." It's that the person who writes the auth should be the first one to forge a token against it.
34
35---
36Read more on the attack in the JWT best-practices RFC.
37
38Notes:
39- The `@clip src=""` and `` use **empty/placeholder** sources on purpose, so they render the framed placeholders without needing an upload — that's how you confirm the layout. To use real media, click **+ upload image / clip** and it'll drop a snippet with a real URL.
40- After **Create post**, you should land back on `/admin` with it listed, and see it at `/blog` and `/blog/hardening-a-jwt-auth-flow-i-shipped-too-fast`.
security6 min read
More posts