back to the blog
Case Studysecurity

Hardening a JWT auth flow I shipped too fast

I rolled my own session handling, then attacked it. Three holes I found, and the boring fixes that closed them.

AMAnas Malik·Jun 2026· 6 min

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:

verify.tsts
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