All posts
VibeZero Team11 min read

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.

ShareXLinkedIn