JWT alg none production lessons
Why the old alg none bug still matters, how it appears in modern services, and how to test for it without theater.
The alg: none story gets treated like ancient JWT folklore, but I still check for it on real assessments. Not because every library accepts unsigned tokens now, but because production systems rarely validate tokens in one neat place. A gateway may verify the signature, a backend worker may only decode claims, and an internal admin route may trust a header that was supposed to be private. That is how an old class of bug keeps finding new rooms to live in.
What actually fails
The failure is not usually a library proudly saying, "unsigned tokens are fine." It is a developer path where verification was optional during local testing and the flag survived. In Node, Python, Java, and Go stacks, the dangerous shape looks similar: code decodes a token to read sub, role, or tenant_id, then later assumes some other layer must have checked it. Attackers do not need elegance here. They remove the signature, set the header to {"alg":"none"}, and change one claim that has visible authorization impact.
A useful test is to compare responses, not just status codes. If /api/me rejects the token but /api/admin/reports returns a different error after the modified claim, you learned something. Maybe the token was parsed before verification. Maybe authorization logs now contain attacker supplied identifiers. Those are not harmless findings.
A practical test sequence
Start with a valid token from a low privilege account. Decode it locally, keep the payload shape intact, and create an unsigned variant. Change only one claim at a time. role is noisy, but tenant_id, scope, aud, and feature flags often reveal more. Send the token to endpoints that sit behind different middleware, especially background job APIs, GraphQL resolvers, file download routes, and admin JSON endpoints that were added after the main authentication layer.
What defenders should log
Verification failures should be boring and early. Log the token header algorithm, key id, issuer, audience, request path, and reason for rejection, but never the raw token. Alert on alg: none specifically because it signals active manipulation rather than normal expiry. Also test the negative path in CI. A unit test that passes an unsigned token through every verifier is cheap insurance against a future refactor that changes defaults.
The production lesson
The fix is policy, not just a library upgrade. Verifiers should pin allowed algorithms in code, reject tokens before claim parsing, and expose one internal interface for trusted claims. If a handler can call a generic decode helper and get a dictionary back, someone will eventually use it for authorization. Make the safe path shorter than the unsafe one.