JWT Signing Algorithms
The alg field in the JWT header declares which algorithm signed it. Three families: HMAC (symmetric, shared secret), RSA (asymmetric), ECDSA (asymmetric, smaller keys). Choosing wrong — or letting an attacker choose — is a common interview gotcha.
The algorithm families
| Family | Examples | Key type | When |
|---|---|---|---|
| HMAC | HS256, HS384, HS512 | shared secret (symmetric) | one party issues + verifies (monolith, internal service) |
| RSA | RS256, RS384, RS512 | private key signs, public verifies | issuer ≠ verifier (OIDC, microservices) |
| ECDSA | ES256, ES384, ES512 | private/public, smaller than RSA | modern asymmetric default |
| EdDSA | EdDSA | Ed25519 keys | newer; rolling out |
| none | none | (no signature) | never use |
The number is the SHA hash size. HS256 = HMAC + SHA-256; RS256 = RSA + SHA-256.
HS256 — when both sides share the secret
import jwt
token = jwt.encode({"sub": "user_42"}, "supersecret", algorithm="HS256")
decoded = jwt.decode(token, "supersecret", algorithms=["HS256"])
Same secret signs and verifies. Simple, fast. Works when:
- One service issues and verifies (e.g., a monolithic web app).
- Two services share infrastructure and can safely share the secret.
Pitfall: weak secrets. secret = "secret" makes the JWT trivially forgeable via brute force. Use a 256+ bit random secret (secrets.token_urlsafe(32)).
RS256 — issuer signs, verifiers use the public key
# Issuer
with open("private.pem", "rb") as f:
private_key = f.read()
token = jwt.encode({"sub": "user_42"}, private_key, algorithm="RS256")
# Verifier
with open("public.pem", "rb") as f:
public_key = f.read()
decoded = jwt.decode(token, public_key, algorithms=["RS256"])
Asymmetric. The private key never leaves the issuer. Many verifiers can hold the public key (which is public — distribute freely).
Used by:
- OIDC providers (Okta, Auth0, Google) — they publish their public keys via JWKS at
<issuer>/.well-known/jwks.json. Your app fetches and caches. - Microservice meshes where one auth service issues; many services verify.
- Cross-organization tokens (federation).
ES256 — like RS256 but smaller
ECDSA with P-256 curve. Same asymmetric model as RSA but:
- Smaller keys (~256 bits vs RSA’s 2048-4096).
- Faster signing (slower verification, but both are fast).
- Smaller signatures.
Becoming the default for new systems. Most JWT libraries support it.
The alg: none attack
The protocol allows alg: none (no signature). Old libraries verified such tokens as legitimate:
// header
{ "alg": "none", "typ": "JWT" }
// payload
{ "sub": "admin", "role": "superuser" }
Concatenate, base64url, leave signature empty. Some libraries accepted this as “validly signed” because the alg said none.
# DANGEROUS — accepts whatever alg the token claims
jwt.decode(token) # might accept alg=none
# SAFE — pin allowed algorithms
jwt.decode(token, key, algorithms=["RS256"]) # rejects alg=none and HS256
Modern libraries (PyJWT 2+) fail safe by default. But:
verify_signature=Falsereintroduces it.- Custom decoders may not.
- Library upgrades sometimes change defaults.
Always specify algorithms=[...] as an explicit allowlist. Never accept a token if you can’t list the allowed algorithm in advance.
The algorithm confusion attack (RS256 ↔ HS256)
The famous one. Setup: your server expects RS256 (asymmetric). Code:
# Vulnerable
public_key = load_public_key()
jwt.decode(token, public_key) # no algorithms= argument
Attacker:
- Crafts a token claiming
alg: HS256. - Signs it using your public key as the HMAC secret.
The verifier:
- Reads
alg: HS256from the token. - Calls
hmac_sha256(public_key, ...)— using the public key as the HMAC secret. - The signatures match. Token is accepted.
Why this works: HMAC and RSA don’t share a key namespace, but the library accepts a “key” of any type. Public keys are public; the attacker has them.
Defense:
jwt.decode(token, public_key, algorithms=["RS256"]) # rejects HS256
Pin the algorithm. Never accept “whatever the token says.” Modern libraries refuse to use a “public” key with a symmetric algorithm by default; older versions don’t.
Key ID (kid) and key rotation
For rotation, the header includes a key identifier:
{ "alg": "RS256", "kid": "key-2024-q1" }
The verifier looks up the key by kid:
def get_key(unverified_header):
kid = unverified_header["kid"]
return KEYS[kid] # dict mapping kid → key
header = jwt.get_unverified_header(token)
key = get_key(header)
decoded = jwt.decode(token, key, algorithms=["RS256"])
For OIDC, the JWKS endpoint provides all currently-valid keys. Your app:
- Fetches JWKS periodically (or on signature failure).
- Caches per
kid. - On rotation: old
kidkeeps working until cache refresh; newkidworks immediately.
The kid field is what makes “rotate the signing key without downtime” possible.
Pitfall: kid is attacker-controlled. Some early implementations used kid as a file path or DB query parameter without sanitization → arbitrary file read / SQL injection. Treat kid as untrusted input; allowlist lookups (dict, fixed file location, JWKS).
Choosing an algorithm
| Scenario | Pick |
|---|---|
| Single monolithic service signs + verifies | HS256 with strong secret |
| Multiple services verify; one signs (microservices, OIDC) | RS256 (universal) or ES256 (smaller) |
| New green-field, modern stack | ES256 or EdDSA |
| Compatibility with old systems | RS256 |
| Need smallest tokens | ES256 (smaller signature than RS256) |
Avoid:
- HS256 across microservices — every service has the secret; one compromise = all forge.
none— ever.- HS384/HS512 thinking it’s “stronger” — HS256 with a 256-bit secret is plenty.
- Custom algorithms — stick to spec.
Algorithm parameters
Some library quirks:
- PyJWT:
jwt.encode(..., algorithm="HS256")(singular).jwt.decode(..., algorithms=["HS256"])(plural, list). - python-jose: similar API but slightly different.
- Node
jsonwebtoken:verify(token, key, { algorithms: ["RS256"] }).
Always check your library’s docs for the exact parameter name. The defensive pattern is “specify allowed algorithms explicitly” regardless of library.
Common pitfalls
- Not pinning
algorithms=[...]— alg confusion + alg=none attacks. - Using HS256 with a weak secret — brute-forceable. Use 256+ bit random.
- Sharing HS256 secrets across services — one breach = everyone forges.
- Loading
kidinsecurely — attacker-controlled value reaching a file path / SQL query. - Mixing keys — calling
jwt.decode(token, jwks_public_key)when token was signed with another key. Look up bykid. - Logging the full JWT — anyone with log access has all your sessions until tokens expire.
Common interview confusions
- “HS256 is less secure than RS256.” — different use cases. HS256 with strong secret is cryptographically fine; the issue is key distribution. Multiple services needing to verify? RS256.
- “
alg: noneis fine for testing.” — never. Disable in code paths that run anywhere near production. - “Bigger SHA = more secure.” — HS512 is overkill for typical use; HS256 with proper secret is fine.
Interview angle
- “What signing algorithms does JWT support?” — three families: HMAC symmetric (HS256/384/512), RSA asymmetric (RS256/384/512), ECDSA asymmetric (ES256/384/512). Plus EdDSA. Modern default: ES256 or RS256.
- “HS256 vs RS256 — when each?” — HS256: one service signs and verifies (one secret to manage). RS256: issuer signs with private key, many verifiers use the public key (OIDC, microservices). Asymmetric scales better but is more complex.
- “What’s the alg=none attack?” — token claims
alg: none, no signature; vulnerable libraries accept it. Defense: always pinalgorithms=[...]to an allowlist. - “What’s the algorithm confusion attack?” — RS256 verifier called without specifying algorithm; attacker sends token with
alg: HS256signed using the verifier’s public key as the HMAC secret. Library uses public key as symmetric key, accepts the forgery. Defense:algorithms=["RS256"]. - “What’s
kidand why?” — key ID in the JWT header. Tells the verifier which key signed (for rotation). The verifier looks up the key in a JWKS or local key store. Critical for zero-downtime key rotation. - “How do you rotate signing keys?” — issuer adds new
kidto its signing rotation; updates JWKS endpoint to include both old and new public keys; verifiers fetch JWKS periodically and verify bykid. After all old-kidtokens have expired, remove the old key. - “What goes wrong if you trust
kidfrom the token without validation?” — attacker setskidto a path like../../etc/passwdor SQL injection payload. Treat as untrusted input; use allowlisted lookups (dict, JWKS).