JWT alg=none, explained
The JWT header tells the verifier which algorithm to use, and the header is written by whoever sends the token. Why alg=none still ships, and the fix.
- security
- vibe coding
- engineering
A JSON Web Token is three base64url segments joined by dots: a header, a payload, and a signature. The signature is what makes the payload trustworthy. The header is what tells the verifier how to check the signature.
Read that twice, because the whole bug class is in the second sentence. The instruction for how to verify the token travels inside the token, and the token came from the client. You are asking the attacker which lock to use.
alg: none is the answer where the attacker says: no lock. It is a real
registered value, defined in
RFC 7518 section 3.6 for tokens whose
integrity is guaranteed by some other layer, so a library that implements the
specification faithfully will implement it. The specification also anticipated
precisely this problem, and said so in the same section: implementations
supporting unsecured tokens “MUST NOT accept such objects as valid unless the
application specifies that it is acceptable for a specific object to not be
integrity protected.”
That condition is the whole ballgame, and it is the one that gets skipped.
Somebody calls verify() without saying which algorithms they will accept, the
library falls back to reading the header to find out, and the answer is none.
The identifier is CWE-347, improper verification of a cryptographic signature, and the reference material is RFC 8725, the JWT best current practices document that exists largely because of this.
The attack, in full
There is no exploit to write. You edit a JSON object.
Take any token the application issued you, a token from a free account you signed up for five minutes ago:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5MSIsInJvbGUiOiJ1c2VyIn0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
Decode the first segment, which is not encrypted and never was:
{ "alg": "HS256", "typ": "JWT" }
Change two things and re-encode. The header loses its algorithm, the payload gains a promotion:
{ "alg": "none", "typ": "JWT" }
{ "sub": "91", "role": "admin" }
Then join them with a dot, add a trailing dot, and leave the third segment empty:
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiI5MSIsInJvbGUiOiJhZG1pbiJ9.
Send it. A vulnerable verifier reads none, concludes there is nothing to
check, reports success, and hands your application a payload that says the caller
is an administrator. Every downstream authorization decision is then correct
reasoning applied to a forged fact, which is why this reads as
privilege escalation in the logs rather
than as an authentication failure. Nothing failed. The check returned true.
Why libraries keep shipping it
This was described publicly in 2015, in
Auth0’s writeup of critical vulnerabilities in JWT libraries,
and RFC 8725 spells out the mechanism in section 2.1: the algorithm “can be
changed to none by an attacker, and some libraries would trust this value and
validate the JWT without checking any signature.”
Eleven years later it keeps returning, and the returns are instructive because none of them is a library author forgetting the attack exists.
A permissive default.
CVE-2022-23540 in Node’s
jsonwebtoken: in versions up to 8.5.1, a token arriving with no signature, a
jwt.verify() call with no algorithms option, and a falsy secret, null or
false or undefined, together fell back to none and the token was accepted.
Version 9.0.0 removed the default, and
Auth0’s bulletin
documents it alongside two sibling issues. Note the shape: an application that
lost its secret to a misread environment variable did not fail closed with a
verification error, it started accepting forgeries.
A regression, found by someone else’s test suite.
CVE-2026-28802 in Authlib,
a widely deployed Python OAuth and OIDC library. Setting
alg: none with a blank signature passed verification in 1.6.5 and 1.6.6, a
regression introduced somewhere after 1.5.2, rated 7.7 and fixed in 1.6.7. The
advisory says how it surfaced: a downstream project’s tests, which asserted that
a forged alg: none token would be rejected, started passing the verification
step when they were supposed to fail. Nobody in the dependency chain would have
noticed otherwise, because from the application’s point of view authentication
was working perfectly.
Case and spelling. A library that blocks the literal string none may not
block None, nOnE or NONE, and a library that dispatches on the algorithm
name may treat an unrecognised value as “no algorithm to run” rather than as an
error. The generalised rule is that any comparison against a denylist of bad
algorithm names is the wrong control. The right control is an allowlist of the
one or two algorithms you actually use.
The same bug wearing a real algorithm
Blocking none does not close the class, because the attacker has a second move:
leave a real algorithm in the header and change which one.
An asymmetric setup signs with a private key and verifies with a public key. A
symmetric setup signs and verifies with one shared secret. If the verifier picks
its mode from the header, an attacker can flip RS256 to HS256 and sign the
forged token using the RSA public key as the HMAC secret. The verifier, told to
do HMAC, uses the only key it has, which is the public key, and the signature
matches. The attacker needed no secret at all: they needed a value that is
published on purpose.
This is algorithm confusion. RFC 8725 catalogues it in the same section 2.1,
right beside the none attack, which is a fair signal that the two are one
problem with two spellings. It is also still landing.
CVE-2026-34950 in fast-jwt
is the pure form of it. The library defends itself by detecting whether the
provided key looks like a PEM public key, and the regex that does that detection
is anchored with ^, which any leading whitespace defeats. A public key stored
in an environment variable with a stray newline in front of it, verified by a
call that did not name an algorithm, and the RS256-to-HS256 attack is back. The
advisory is explicit that this is an incomplete fix for the same issue reported
in 2023. Versions up to 6.1.0 are affected, 6.2.0 fixes it.
The lesson is not that these libraries are bad. It is that every one of these bugs lives in the branch that runs when the caller did not say which algorithm they wanted, and you can delete that branch from your own application in one line.
The fix
Name the algorithms at every verification call. Not in a config file that some code paths read, at the call.
import jwt from "jsonwebtoken";
// The header is input. It never decides how the token is checked.
const claims = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ["HS256"], // allowlist, not denylist
issuer: "https://app.example.com",
audience: "api",
});
import os
import jwt # PyJWT
claims = jwt.decode(
token,
key=os.environ["JWT_SECRET"],
algorithms=["HS256"], # required argument, and that is the point
issuer="https://app.example.com",
audience="api",
)
Two things about the Python version are worth pointing out, because the naming
across ecosystems is actively misleading. PyJWT’s decode is the verifying
call: it checks the signature by default, and it makes algorithms a required
argument, which is the correct API design because the safe thing becomes the only
thing you can express. Node’s decode is the opposite and verifies nothing. Same
word, opposite guarantee. Where a library lets you omit the allowlist, treat the
omission as a bug in your code rather than a default you inherited.
The issuer and audience arguments are there for a reason too: a token your
own system signed for a different purpose is a valid signature attached to the
wrong claims. Both libraries also check exp on this path, though only when the
claim is present, which makes expiry a separate control with
its own failure modes rather than something
the signature gives you.
Never confuse decoding with verifying. jwt.decode() in Node’s
jsonwebtoken and jwtDecode from the jwt-decode package parse the token and
return the payload without checking anything. The latter’s README says so in
capitals: “This library doesn’t validate the token, any well-formed JWT can be
decoded.” They are for reading a token you already trust, and in generated
frontend code they are usually rendering a username, which is fine. The failure
is when the same habit reaches a route handler and a
claim that was only ever decoded ends up deciding what the caller may read. That
is broken access control with an
authentication-shaped alibi.
Bind the key to the algorithm. If you verify with an HMAC secret, the token
must be HMAC. If you verify with a public key, the token must be asymmetric. Most
libraries will do this once you pass an allowlist, which is the second reason the
allowlist is worth more than a none check.
Reject before you parse. An allowlist checked against the header before any cryptography runs also protects you from whatever the library does with an algorithm it has never heard of.
Where this reaches a generated app
Most AI-built apps do not verify JWTs by hand, at first. On Supabase the client library holds the session and the database checks the claims, so the verification you depend on happens in infrastructure you did not write. That is a genuinely good position to be in.
It ends the first time a feature needs a server. An Edge Function, a route
handler, a webhook receiver, anything that reads an Authorization header and
wants to know who is calling. That is the moment a verification call gets written
by whoever is fastest, and the fast version is decode rather than verify,
because decode needs no key and works immediately.
The correct version on Supabase is to hand the token back to the auth service rather than to inspect it yourself, which is the pattern in how to secure a Bolt app:
const jwt = event.headers.authorization?.replace("Bearer ", "") ?? "";
const { data: { user }, error } = await supabase.auth.getUser(jwt);
if (error || !user) return new Response("Unauthorized", { status: 401 });
If you do verify locally for latency, verify against the project’s published signing key with an explicit algorithm allowlist, and remember that the service role key is itself a JWT: anything that mints or accepts tokens in your project is standing next to a credential that ignores every policy you wrote.
What a scanner can and cannot see here
Our SAST engine, OpenGrep, runs a rules bundle baked into the worker image and
scoped to the languages we detected in your repository. Two rules in that bundle
are the ones that matter for this post. jwt-none-alg flags an explicit use of
the none algorithm in JavaScript or TypeScript, carries CWE-327, and ships at
ERROR, which our engine normalises to HIGH. Its sibling
hardcoded-jwt-secret is the subject of
the weak secret post.
Now the honest part. jwt-none-alg catches the case where somebody wrote
algorithm: "none" on purpose, which is rare. It does not catch a verify()
call with a missing algorithms option, because a missing argument is not a
pattern in a line of code, and it cannot know whether the decode call it sees
in your route handler feeds a rendered username or an authorization branch.
The layer that sees a real token instead of a line of code is the runtime one.
Our scanner loads the deployed page and the scripts it pulls in, decodes every
JWT it finds there and verifies none of them, which is the right posture for an
outside observer. jwt-alg-none reports a token in the page whose header says
none, at critical severity, and the wording we ship is careful about what it
can actually prove: an unsigned token only matters if your server accepts it,
and a scan standing outside cannot see that. It is evidence, not a verdict on
your verifier.
That check is filed under CWE-347, the identifier at the top of this post, while
the OpenGrep rule above files the same thing under CWE-327 for a broken or risky
algorithm. We went with 347 deliberately. The defect is not that none is a weak
algorithm, it is that the signature was never checked.
What does close the loop is the layer beside it. A vulnerable
jsonwebtoken@8.5.1, authlib==1.6.6 or fast-jwt@6.1.0 in a lockfile is a
dependency finding, and OSV-Scanner and Grype both report it with the CVE
attached. That is why the two engines run in the same pipeline and why the
release decision is made across layers
rather than per tool: the code layer sees the call you wrote, the dependency
layer sees the branch you inherited, and this bug class has lived in both.
The test that takes two minutes
Get a token from your own application. Rewrite the header to {"alg":"none"},
strip the signature, keep the trailing dot, and send it to an endpoint that
returns something belonging to you. Then do it again with the sub or role
claim changed to somebody else’s.
A 401 is the answer you want. A 200 with your own data is worse than it looks,
because it means the payload was accepted, and the only reason you did not see
someone else’s data is that you did not edit the payload hard enough. Repeat with
None and NONE before you conclude anything, since those are three different
strings to a comparison and one identical instruction to a reader.
The property you are testing for is not “does my app reject none”. It is
whether any part of your application decides how to verify a token by reading the
token. If the answer is anywhere yes, the signature is a formality.