CyberNugget

L25 · JWTs and Their Pitfalls

Module 4: Identity and Access · ~20 min · Lesson 25 of 71

🎯 What you'll learn

  • Why a JWT's header is attacker-controlled input, and the four attacks that abuse it
  • The checks a verifier must run, in order, before trusting a single claim
  • Why "stateless logout" doesn't exist, and what to do instead
  • Where a browser app should keep tokens (and why the best answer is "not in the browser")

🔥 Why this matters

In March 2015, Tim McLean showed that several popular JWT libraries could be fooled into accepting tokens nobody had signed. The bug wasn't in the cryptography: the libraries let the token choose how it should be checked. The same class of mistake keeps coming back: Node's jsonwebtoken fixed related issues again in version 9.0.0 (2022). Gateways aren't immune: a Kong route with the jwt plugin and an empty claims_to_verify will accept a token that expired last year.

1. Three parts, and who controls them

L17 decoded an HS256 token and showed that signing is not encrypting: anyone can read the claims. Today the question is different: what does the verifier do with each part?

  eyJhbGciOiJSUzI1NiIs...  .  eyJpc3MiOiJodHRwczov...  .  Xk2pQ...
  └──── HEADER ────────┘      └──── PAYLOAD ─────────┘      └ SIG ┘
  {"alg":"RS256",             {"iss":"https://auth.        sign(
   "typ":"at+jwt",              example.com",               header
   "kid":"k-2026-11"}          "sub":"C-30582",             + "." +
                               "aud":"policy-api",          payload)
                               "exp":1793974500,
                               "jti":"7f3a-91c2"}
          │                            │                      │
          ▼                            ▼                      ▼
  tells the verifier          the facts you act on   proves the
  HOW to check the token      AFTER checks pass      first two parts
  (attacker can edit it)                             weren't changed

The trap is in the left column. Until the signature is checked, the header is just text the client sent, and a library that obeys its alg or kid lets the attacker choose the lock.

What you see above is a JWS (JSON Web Signature, RFC 7515): signed, readable, three parts. A JWE (JSON Web Encryption, RFC 7516) has five parts and its payload is encrypted. Most access tokens are JWS; use JWE only when claims must stay hidden from the client.

The registered claims (RFC 7519) are the ones you validate:

Claim Meaning Check
iss Issuer: who minted it Exactly your IdP's URL
aud Audience: who it's for Contains your API
exp Expiry time (Unix seconds) In the future
nbf Not before In the past
iat Issued at Sanity only
jti Unique token ID Used for denylists and replay checks

2. Four ways to abuse the header

 Header the attacker sends          What a naive verifier does
 ─────────────────────────────────  ───────────────────────────────
 a) "alg":"none"                    skips the signature check
 b) "alg":"HS256" (key is RSA)      uses the PUBLIC key as an HMAC
                                    secret the attacker also knows
 c) "kid":"../../../dev/null"       loads an empty file as the key
    "kid":"x' UNION SELECT 'k'--"   runs it into a SQL key lookup
 d) "jku":"https://evil.example/k"  fetches the attacker's keys
    "jwk":{...attacker's key...}    verifies with the key provided

a) alg: none. RFC 7518 defines none for an unsecured JWS, with no signature at all. The attacker edits the payload, sets "alg":"none", and sends an empty signature. Never accept it.

b) Algorithm confusion. Your API expects RS256 tokens and has the issuer's public key. The attacker writes a token with "alg":"HS256" and computes an HMAC using that public key as the secret. A library that does "HS256 → HMAC with the key I was given" verifies it. This was CVE-2015-9235 in Node's jsonwebtoken before 4.2.2. The first deep dive walks through it.

c) kid injection. The kid (key ID) header says which key signed the token. If your code builds a file path or SQL query from it, the attacker picks the key. /dev/null gives an empty key they can sign with.

d) jku, x5u and jwk. These headers point to a key set URL, a certificate URL, or carry a key inline. Following them trusts whatever key the attacker supplies (Cisco's node-jose accepted embedded keys: CVE-2018-0114). Fetching an attacker's URL is also SSRF (L35).

One more: weak HS256 secrets. Any captured HS256 token lets an attacker guess the secret offline, as fast as their GPUs allow (hashcat has a JWT mode, -m 16500). RFC 7518 requires an HS256 key of at least 256 bits, and RFC 8725 says a human-memorable password must never be one.

3. Verifying a JWT properly

RFC 8725 (JWT Best Current Practices, 2020; a revision was in the RFC Editor queue in late 2026) boils down to one rule: the server decides how to verify; the token only gets to say which of the server's keys to use.

 token ──▶ 1. parse; reject if not 3 parts or bad base64url
           2. alg in MY allow-list for this key?  (no "none")
           3. kid → key from MY JWKS, fetched from MY config
              (a map lookup: never a path, query, or URL)
           4. verify the signature with that key
           5. typ is what I expect (e.g. at+jwt, RFC 9068)
           6. iss == my IdP, aud contains me
           7. exp > now, nbf <= now  (leeway ~30-60 s)
           8. THEN authorize: scope, roles, object owner (L27)
                │ any step fails
                ▼
            401, and log why (without the token)

Steps 2 and 3 defeat every attack in section 2. Step 6 stops cross-service replay: a token issued for claims-api mustn't work on policy-api just because both trust the same IdP. Don't hand-write this: configure a maintained library strictly.

4. Expiry and revocation: the "stateless" myth

A JWT is valid until exp, whatever happens on the server. If a customer logs out, changes their password, or reports a stolen phone, tokens already issued keep working. "Stateless logout" only deletes the client's copy; a thief's copy still works.

The standard design limits the damage:

 time ──────────────────────────────────────────────────────▶
 access token   [─15m─][─15m─][─15m─][─15m─]   short, a JWT,
                   ▲      ▲      ▲      ▲      sent to APIs
 refresh token  [════════ hours to days ═══════════]
                  each refresh returns a NEW refresh token
                  (rotation); reusing an old one = theft
                  → revoke the whole family
  • Short-lived access tokens (5-15 minutes) cap how long a stolen one is useful.
  • Refresh tokens go only to the authorization server, which keeps state and can revoke them. RFC 9700 (OAuth Security BCP, 2025) requires refresh tokens for public clients to be sender-constrained or rotated.
  • For emergencies, a denylist by jti checked on each request. That's server-side state again, which is the point: real revocation needs state somewhere. The second deep dive compares options.

5. Where should a browser keep tokens?

Storage XSS can steal it? Notes
localStorage / sessionStorage Yes: any script reads it Easiest, and the most common mistake
JavaScript memory Harder, but XSS can still use it Lost on page reload
HttpOnly; Secure; SameSite cookie No (JS can't read it), but XSS can send requests with it Needs CSRF thinking (L22)
BFF: tokens stay on the server Nothing to steal in the browser Browser holds only a session cookie

A Backend for Frontend (BFF) is a small server-side component of your web app that does the OAuth flow, keeps the tokens, and calls APIs for the browser. RFC 10017 (OAuth 2.0 for Browser-Based Applications, August 2026) calls a BFF "strongly recommended" for business and sensitive apps and ones that handle personal data, and advises against browser-only OAuth clients for them. A customer portal showing policy and payment data is exactly that case.

🧪 Worked example: a policy API that trusts too much

policy-service (Python, behind Kong) checks tokens from https://auth.example.com. Before:

import jwt   # PyJWT 2.x

def get_claims(token):
    alg = jwt.get_unverified_header(token)["alg"]   # ✗ token picks
    return jwt.decode(token, PUBLIC_KEY,
                      algorithms=[alg])            # ✗ no aud/iss

Problems: the token picks its own algorithm (PyJWT's docs warn against exactly this), nothing checks iss or aud, and exp is checked only if present, so a token without one never expires. PyJWT does refuse PEM public keys as HMAC secrets (its CVE-2017-11424 and CVE-2022-29217 fixes), but don't rely on a library guard. After:

import jwt

JWKS = jwt.PyJWKClient(
    "https://auth.example.com/.well-known/jwks.json")  # from config

def get_claims(token):
    key = JWKS.get_signing_key_from_jwt(token)  # kid → trusted set
    return jwt.decode(
        token, key.key,
        algorithms=["RS256"],                   # pinned
        issuer="https://auth.example.com",
        audience="policy-api",
        leeway=30,
        options={"require": ["exp", "iat", "sub", "jti"]},
    )

Node's equivalent is jwt.verify(token, getKey, { algorithms: ['RS256'], issuer, audience }). At the gateway, Kong's jwt plugin pins the algorithm per credential and rejects anything else (Invalid algorithm), but it checks exp only if you ask:

plugins:
- name: jwt
  config:
    key_claim_name: iss          # default; picks the credential
    claims_to_verify: [exp, nbf] # no default: unset = no expiry!
    maximum_expiration: 900      # reject tokens living > 15 min
consumers:
- username: auth-example
  jwt_secrets:
  - key: https://auth.example.com   # must equal the iss claim
    algorithm: RS256                # default would be HS256
    secret: unused                  # required field, dummy value
    rsa_public_key: |
      -----BEGIN PUBLIC KEY-----
      (made-up key goes here)
      -----END PUBLIC KEY-----

Kong's jwt plugin doesn't check aud and doesn't read a JWKS URL, so the service still checks the audience (defense in depth, L04).

🛠 Try it (optional, ~5 min)

All local. A made-up HS256 token (claims invented, secret deliberately weak). Paste into a shell:

T='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.'\
'eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJDLTMwNTgy'\
'IiwiYXVkIjoicG9saWN5LWFwaSIsInNjb3BlIjoicG9saWNpZXM6cmVhZCIsImlh'\
'dCI6MTc5Mzk3MzYwMCwiZXhwIjoxNzkzOTc0NTAwLCJqdGkiOiI3ZjNhLTkxYzIi'\
'fQ.4if1NVM4PbdM_XroQrUZ5agb5-BPwy-C7mkyKlNmaLA'
b64url_decode() {       # base64url → base64, then add padding
  local s; s=$(printf '%s' "$1" | tr '_-' '/+')
  while [ $(( ${#s} % 4 )) -ne 0 ]; do s="$s="; done
  printf '%s' "$s" | base64 -d
}

1. The naive way first. The payload is 195 characters, not a multiple of 4, because JWTs strip = padding:

$ printf '%s' "$T" | cut -d. -f2 | base64 -d
{"iss":"https://auth.example.com", ... ,"jti":"7f3a-91c2"

On macOS the closing } is silently dropped (exit code 0), so jq fails with Unfinished JSON term. GNU base64 on Linux prints it all but exits 1 with invalid input, and also rejects base64url's - and _ (macOS accepts them). The helper handles both.

2. Decode properly:

$ b64url_decode "$(printf '%s' "$T" | cut -d. -f1)"
{"alg":"HS256","typ":"JWT"}
$ b64url_decode "$(printf '%s' "$T" | cut -d. -f2)" | jq .
{
  "iss": "https://auth.example.com",
  "sub": "C-30582",
  "aud": "policy-api",
  "scope": "policies:read",
  "iat": 1793973600,
  "exp": 1793974500,
  "jti": "7f3a-91c2"
}
$ date -u -r 1793974500        # Linux: date -u -d @1793974500
Fri Nov  6 14:15:00 UTC 2026

A 15-minute token. Look for what a verifier would check: iss, aud, exp, jti.

3. Guess the secret offline, like an attacker with a captured token would (a five-word "wordlist"; real ones have billions of entries):

DATA=${T%.*}; SIG=${T##*.}
for k in secret changeme password1 summer2026 jwt-key; do
  s=$(printf '%s' "$DATA" | openssl dgst -sha256 -hmac "$k" -binary \
      | base64 | tr '+/' '-_' | tr -d '=')
  [ "$s" = "$SIG" ] && echo "secret is: $k" && break
  echo "not: $k"
done
not: secret
not: changeme
not: password1
secret is: summer2026

No server was contacted, and now the attacker can mint any token. (Works with Homebrew OpenSSL and macOS's LibreSSL.)

🧠 Key takeaways

  • The JWT header is untrusted input: never let alg, kid, jku or jwk decide how you verify.
  • Pin the algorithm per key, take keys only from your own JWKS, then check iss, aud, exp, nbf.
  • HS256 secrets need 256+ random bits; one captured token is enough to brute-force a weak one.
  • JWTs can't be un-issued: use short access tokens, rotated refresh tokens, and a jti denylist for emergencies.
  • In browsers, prefer a BFF; never localStorage for tokens that reach financial or personal data.

📖 Words to know

Term Meaning
JWS A signed JWT: header.payload.signature, readable by anyone
JWE An encrypted JWT with five parts; the payload is hidden
Unsecured JWS A token with "alg":"none" and no signature; never accept it
Algorithm confusion Tricking a verifier into using a different algorithm than the key was meant for, e.g. RS256 → HS256
kid Key ID header: which of the issuer's keys signed the token
jku / x5u / jwk Headers that point to, or carry, a key; don't follow them
Refresh token rotation Each refresh returns a new refresh token; reuse of an old one signals theft
jti denylist A server-side list of revoked token IDs, kept until each token's exp
BFF (Backend for Frontend) A server-side part of a web app that holds tokens so the browser never sees them

⏭ Next time

L26: SAML and enterprise SSO, the XML-based cousin of these tokens, where signature-wrapping attacks and "Golden SAML" show what happens when an assertion's signature covers the wrong thing.

Deep dive 1

Algorithm confusion step by step

The setup

Your API verifies RS256 tokens. It gets the issuer's public key from a JWKS, which is public by design (anyone can download it; that's what lets many services verify). The attacker has no private key and no secrets, only that public key.

The attack

 Attacker                                  Vulnerable verifier
 ────────                                  ───────────────────
 1. GET /.well-known/jwks.json  ─────────▶ public key (PEM)
 2. header  {"alg":"HS256"}
    payload {"sub":"C-30582","role":"admin"}
 3. sig = HMAC-SHA256(key = PUBLIC KEY BYTES,
                      msg = header.payload)
 4. send token  ─────────────────────────▶ reads alg = HS256
                                           key = "the key I have"
                                             = public key bytes
                                           HMAC(pubkey, h.p) == sig
                                           ✔ "valid", role=admin

Why it works: HMAC (L17) accepts any bytes as a key. The verifier was written as verify(token, key), with one key variable that could be an RSA key or an HMAC secret depending on alg. Once the attacker picks alg, the public key turns into a shared secret, and everyone has it. It's a MAC-vs-signature mix-up (L17) done by the attacker.

🧪 Worked example: reproduce it locally

A condensed version of a script you can run with Python's cryptography package (key generated on your machine, no network):

import base64, hashlib, hmac, json
from cryptography.hazmat.primitives import serialization as ser
from cryptography.hazmat.primitives.asymmetric import rsa

b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
priv = rsa.generate_private_key(public_exponent=65537,
                                key_size=2048)
PUB = priv.public_key().public_bytes(
    ser.Encoding.PEM, ser.PublicFormat.SubjectPublicKeyInfo)

# the attacker knows only PUB
h = b64u(json.dumps({"alg": "HS256"}).encode())
p = b64u(json.dumps({"sub": "C-30582", "role": "admin"}).encode())
sig = b64u(hmac.new(PUB, h + b"." + p, hashlib.sha256).digest())
forged = h + b"." + p + b"." + sig

def naive_verify_hs256(token, key):   # the branch the attacker hits
    hh, pp, ss = token.split(b".")
    good = b64u(hmac.new(key, hh + b"." + pp,
                         hashlib.sha256).digest())
    return hmac.compare_digest(ss, good)

print(naive_verify_hs256(forged, PUB))   # True

The full version (with an RS256 branch and a pinned verifier) printed:

naive : {'sub': 'C-30582', 'role': 'admin'}
pinned: None

The pinned verifier just checked alg == "RS256" before anything else. That one line is the fix.

How libraries defend today

Library Default defence What you should still do
PyJWT 2.x algorithms is required; refuses PEM keys as HMAC secrets Pass one algorithm, plus issuer, audience, require
jsonwebtoken (Node) 9.x No none unless listed; default algorithms follow the key type Pass algorithms: ['RS256'] anyway
nimbus-jose-jwt (Java) Key selector bound to an algorithm Use JWSVerificationKeySelector with one alg
Kong jwt plugin alg must equal the credential's algorithm Set algorithm explicitly; the default is HS256

For Java, nimbus-jose-jwt (used under Spring Security's resource server) makes the binding explicit. Sketch, based on Connect2id's published example (v9.28+ API):

var keys = JWKSourceBuilder.create(new URL(JWKS_URL)).build();
var proc = new DefaultJWTProcessor<SecurityContext>();
proc.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(
        new JOSEObjectType("at+jwt")));
proc.setJWSKeySelector(new JWSVerificationKeySelector<>(
        JWSAlgorithm.RS256, keys));          // alg pinned to keys
proc.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
        "policy-api",                         // required audience
        new JWTClaimsSet.Builder()
            .issuer("https://auth.example.com").build(),
        Set.of("sub", "iat", "exp", "jti"))); // required claims
JWTClaimsSet claims = proc.process(token, null);

With jjwt, Jwts.parser().verifyWith(publicKey) ties the check to the key's type, and parseSignedClaims rejects unsigned tokens.

A cousin: when the library itself is wrong

In April 2022, Neil Madden found that Java 15 to 18's ECDSA verifier accepted a signature of all zeros for any message (CVE-2022-21449, "Psychic Signatures"). An ES256 JWT with an empty-looking signature verified on unpatched JVMs. Oracle's early advisory also listed older versions, and the affected range was disputed; the fix shipped in the April 2022 Critical Patch Update. The lesson: pinning algorithms protects against confusion, but you still need to patch your runtime (L02's KEV and EPSS help you decide how fast).

🧠 Extra takeaways

  • Algorithm confusion turns a public key into a shared secret by switching RS256 to HS256.
  • Bind each key to exactly one algorithm; reject tokens whose alg doesn't match before verifying.
  • Modern libraries have guards, but pass the algorithm list explicitly anyway.
  • Runtime crypto bugs (like CVE-2022-21449) bypass correct code: patch the JVM too.
Term Meaning
Key confusion Using a key with an algorithm it wasn't meant for (another name for algorithm confusion)
Key selector Code that picks a verification key and its allowed algorithm from trusted config
Psychic Signatures CVE-2022-21449: Java 15-18 accepted all-zero ECDSA signatures
Deep dive 2

Revoking a JWT: options and trade-offs

The problem

A customer calls: their phone was stolen while logged into the app. The setup: 15-minute JWT access tokens and 30-day refresh tokens. Kong and policy-service verify JWTs locally, without calling the IdP. What can you actually cut off, and how fast?

The options

Option Cuts off a stolen access token? Cost
Wait for exp After up to 15 min None, if tokens are short
Revoke the refresh token (family) Stops new access tokens One record at the auth server
jti denylist Immediately A lookup on every request
"Valid after" per user Immediately, all that user's tokens A lookup per request (by sub)
Introspection (RFC 7662) or opaque tokens Immediately A call to the auth server per request (cacheable)
Rotate the signing key Immediately, for everyone Logs out every user

There's no free option: every "immediately" row puts state back into verification. The real design question is how much state, and where.

Refresh token rotation with reuse detection

 Phone (owner)          Auth server              Thief (copy of RT1)
 ─────────────          ───────────              ───────────────────
 RT1 ──refresh────────▶ RT1 ok → mark used
     ◀──AT2 + RT2─────
                                          ◀──── refresh with RT1
                        RT1 already used!
                        → revoke RT2 and the
                          whole family
 RT2 ──refresh────────▶ revoked → log in again
                                          ────▶ 401

The auth server can't tell which party is the thief, so it kills the family and forces a fresh login (with MFA, L21). For public clients such as mobile apps and SPAs, RFC 9700 requires either this rotation or sender-constrained refresh tokens (bound to a key the client holds).

🧪 Worked example: a jti denylist in MongoDB

For the stolen phone, support revokes the user's refresh tokens and adds any access tokens still live to a denylist. A TTL index cleans up each entry once the token would have expired anyway:

// mongosh: one-time setup
db.revoked_tokens.createIndex(
  { expiresAt: 1 }, { expireAfterSeconds: 0 })   // delete at exp
db.revoked_tokens.createIndex({ jti: 1 }, { unique: true })

// on revocation (made-up IDs)
db.revoked_tokens.insertOne({
  jti: "7f3a-91c2", sub: "C-30582", reason: "device stolen",
  expiresAt: new Date(1793974500 * 1000) })      // token's exp
# in get_claims(), after jwt.decode() succeeds
if revoked.find_one({"jti": claims["jti"]}, {"_id": 1}):
    raise PermissionError("token revoked")       # → 401

Notes: the list stays tiny because entries only live until exp, and with 15-minute tokens it's rarely needed at all. MongoDB's TTL monitor runs about once a minute, so deletion lags a little; that's harmless here because the token has already expired. Kong's jwt plugin can't consult this list, so the check lives in the service (or a cache such as Redis that both read).

Picking a design

 Is a 15-min window acceptable for this API?
   ├─ yes → short ATs + rotated refresh tokens. Done.
   └─ no (payments, beneficiary changes, admin actions)
        ├─ few revocations → jti denylist / valid-after, cached
        └─ must be exact   → introspection or opaque tokens
                             for these routes only

You can mix: plain JWT checks for GET /v1/policies, introspection for POST /v1/payments. Also revoke on the events that matter: password change, MFA reset, account lock, and "log out everywhere".

🛠 Try it (~5 min, thought exercise)

For one API your team owns, write down the worst thing a stolen access token could do in 15 minutes. Is that acceptable? If not, which row of the options table would you pick, and where would the check run: the gateway, the service, or both?

🧠 Extra takeaways

  • Every real revocation mechanism is server-side state; JWTs just let you choose how little.
  • Short access tokens plus rotated refresh tokens cover most cases; reuse detection catches theft.
  • A jti denylist with a TTL equal to exp stays small and cheap.
  • Use stronger, stateful checks only on high-risk routes.
Term Meaning
Token family All refresh tokens descended from one login; revoked together on reuse
Reuse detection Treating a second use of a rotated refresh token as theft
Token introspection Asking the auth server whether a token is still active (RFC 7662)
Opaque token A random reference string with no readable claims; only the issuer can resolve it
TTL index A MongoDB index that deletes documents automatically after a date

The quiz for this lesson and the Tests section need JavaScript.