Backend / Authentication / JWT / 02_signing_algorithms.md

JWT Signing Algorithms

Updated 7 interview angles 6 min read source
On this page12
  1. The algorithm families
  2. HS256 — when both sides share the secret
  3. RS256 — issuer signs, verifiers use the public key
  4. ES256 — like RS256 but smaller
  5. The alg: none attack
  6. The algorithm confusion attack (RS256 ↔ HS256)
  7. Key ID (kid) and key rotation
  8. Choosing an algorithm
  9. Algorithm parameters
  10. Common pitfalls
  11. Common interview confusions
  12. Interview angle

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

python
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

python
# 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:

json
// 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.

python
# DANGEROUS — accepts whatever alg the token claims
jwt.decode(token)              # might accept alg=none

# SAFE — pin allowed algorithms
# rejects alg=none and HS256
jwt.decode(token, key, algorithms=["RS256"])

Modern libraries (PyJWT 2+) fail safe by default. But:

  • verify_signature=False reintroduces 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:

python
# Vulnerable
public_key = load_public_key()
# no algorithms= argument
jwt.decode(token, public_key)

Attacker:

  1. Crafts a token claiming alg: HS256.
  2. Signs it using your public key as the HMAC secret.

The verifier:

  1. Reads alg: HS256 from the token.
  2. Calls hmac_sha256(public_key, ...) — using the public key as the HMAC secret.
  3. 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:

python
# rejects HS256
jwt.decode(token, public_key, algorithms=["RS256"])

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:

json
{ "alg": "RS256", "kid": "key-2024-q1" }

The verifier looks up the key by kid:

python
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 kid keeps working until cache refresh; new kid works 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 kid insecurely — 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 by kid.
  • 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: none is 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 7

  • “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 pin algorithms=[...] to an allowlist.
  • “What’s the algorithm confusion attack?” — RS256 verifier called without specifying algorithm; attacker sends token with alg: HS256 signed 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 kid and 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 kid to its signing rotation; updates JWKS endpoint to include both old and new public keys; verifiers fetch JWKS periodically and verify by kid. After all old-kid tokens have expired, remove the old key.
  • “What goes wrong if you trust kid from the token without validation?” — attacker sets kid to a path like ../../etc/passwd or SQL injection payload. Treat as untrusted input; use allowlisted lookups (dict, JWKS).