← articles

web · authentication · JWT

JWT "alg": "none": forging an admin token

A verifier that trusts the algorithm named in the token header can be told to skip the signature check.

A JWT is base64url(header).base64url(payload).signature. The header and payload are encoded, not encrypted: anyone can read and edit them. The signature, here HMAC-SHA256 with a server-side secret, is the only integrity protection.

JWT live demo
acme-portal · session token
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsInJvbGUiOiJ1c2VyIiwiZXhwIjoxODkzNDU2MDAwfQ.Xb9kQ2vN7rLw0eTzP4sYh1cJmA8uGdFo3iKqRnBtE6c
header
{
  "alg": ,
  "typ": "JWT"
}
payload
{
  "sub": "alice",
  "role": ,
  "exp": 1893456000
}

Click the underlined values. Make yourself admin: the signature no longer matches. Then tell the server the token needs no signature.

GET /api/admin/users
HTTP/1.1 403 Forbidden
{
  "error": "admin only"
}
claims, err := jwt.Verify(token, key) // uses the token's own "alg": "none" skips the check
Simulated. Runs entirely in your browser.

The bug

Changing "role": "user" to "admin" invalidates the HS256 signature, and the server rejects the token. But the JWT spec (RFC 7519) also defines unsecured tokens: "alg": "none" with an empty signature. A verifier that picks the algorithm from the token header lets the attacker choose none, and the forged payload is accepted.

claims, err := jwt.Verify(token, key) // algorithm read from the token header

Several JWT libraries behaved like this until it was disclosed in 2015. The pattern still shows up in hand-written verification code and in old dependencies.

Related issues

  • Algorithm confusion. An RS256 verifier that also accepts HS256 will validate an HMAC computed with the RSA public key as the secret.
  • Weak HMAC secrets. A single token is enough for an offline brute force (hashcat mode 16500).
  • decode instead of verify. The payload is read but the signature is never checked.
  • No exp check and no revocation. A leaked token stays valid indefinitely.
  • Sensitive data in the payload. It is readable by whoever holds the token.

Fix

Pin the algorithm on the server and require an expiry. With golang-jwt v5:

token, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) {
	return secret, nil
}, jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired())
if err != nil || !token.Valid {
	http.Error(w, "unauthorized", http.StatusUnauthorized)
	return
}

For sensitive operations, read the role from the database rather than from the token. Claims reflect the state when the token was issued.

I test web apps, APIs and LLM integrations. Contact.