backend / authentication / jwt / 02_signing_algorithms.md

JWT Signing Algorithms

7 interview angles 6 min read source

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=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:

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

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:

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 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

  • “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).