JWT Anatomy and Security — Token Structure, Signature Algorithms, and Common Vulnerabilities
A JSON Web Token (JWT, defined in RFC 7519) is a compact, URL-safe token format for passing claims between parties. You've seen them in OAuth/OIDC flows, API authentication, and session management. A JWT looks like xxxxx.yyyyy.zzzzz — three Base64url segments separated by dots. But the simplicity of the format hides a long history of security pitfalls that have led to serious production breaches. This guide covers the structure, signature algorithms, and the attacks you must defend against.
1. The Three-Part Structure
A JWT is header.payload.signature, each part Base64url-encoded:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← header .eyJzdWIiOiJhbGljZSIsImlhdCI6MTcyMzMyMjQwMH0 ← payload .d0v2mPz8bK_LjGq7n6QhW3vK8nR2sT5mN9pV1xYz0aM ← signature (HS256)
Header
The header declares the token type and signing algorithm:
{
"alg": "HS256", // signing algorithm
"typ": "JWT", // token type
"kid": "key-2026-01" // optional: key ID for rotation
}
Payload (Claims)
The payload contains claims — statements about the subject and additional data. There are three types:
| Claim | Type | Meaning |
|---|---|---|
iss | Registered | Issuer — who created the token |
sub | Registered | Subject — who the token is about (user ID) |
aud | Registered | Audience — intended recipient |
exp | Registered | Expiration time (Unix timestamp) |
iat | Registered | Issued at (Unix timestamp) |
nbf | Registered | Not before — token valid after this time |
jti | Registered | JWT ID — unique identifier for the token |
role | Custom | Application-specific: user role |
scope | Custom | OAuth scopes granted |
2. Signature Algorithms: HS256 vs RS256 vs ES256
The signature proves that the token hasn't been tampered with. The algorithm is declared in the alg header field:
| Algorithm | Type | Key Used | Verification | When to Use |
|---|---|---|---|---|
| HS256 | HMAC-SHA256 | Shared secret | Same secret | Single-server, simple apps |
| RS256 | RSA-PKCS1-v1.5 | Private key (2048+ bit) | Public key | Multi-party, OIDC |
| ES256 | ECDSA P-256 | Private key | Public key | Modern, mobile, IoT |
| PS256 | RSA-PSS | Private key | Public key | High-security RSA |
| none | No signature | None | None | Never use |
HS256: HMAC-SHA256
The simplest algorithm — it uses HMAC-SHA256 with a shared secret. The signature is HMAC-SHA256(secret, base64url(header) + "." + base64url(payload)).
# Python: create and verify HS256 JWT
import jwt # PyJWT
secret = "your-256-bit-secret"
# Create token
token = jwt.encode(
{"sub": "alice", "exp": 1723360800, "iat": 1723322400},
secret, algorithm="HS256"
)
# Verify token — will raise ExpiredSignatureError if expired
claims = jwt.decode(token, secret, algorithms=["HS256"], audience="myapp")
# {"sub": "alice", "exp": 1723360800, "iat": 1723322400}
RS256: RSA Signature
RS256 uses RSA with PKCS#1 v1.5 padding. The private key signs; the public key verifies. This allows anyone with the public key to verify tokens, but only the private key holder can create them:
# Python: RS256 with public/private key pair
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
# Generate key pair (do once, store securely)
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
# Serialize for storage
priv_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
pub_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
# Sign JWT
token = jwt.encode(payload, priv_pem, algorithm="RS256")
# Verify with public key only
claims = jwt.decode(token, pub_pem, algorithms=["RS256"], audience="myapp")
ES256: ECDSA with P-256
ES256 uses Elliptic Curve Digital Signature Algorithm (ECDSA) over the NIST P-256 curve. It produces compact 64-byte signatures while providing equivalent security to a 3072-bit RSA key:
| Algorithm | Key Size | Signature Size | Equivalent Symmetric Security |
|---|---|---|---|
| HS256 | 256 bits (shared secret) | 32 bytes | 128 bits |
| RS256 | 2048 bits | 256 bytes | 112 bits |
| ES256 | 256 bits | 64 bytes | 128 bits |
| RS384 | 3072 bits | 384 bytes | 128 bits |
| ES384 | 384 bits | 96 bytes | 192 bits |
3. Signature Verification: The Right Way
The most common JWT vulnerability comes from incorrect verification. Here's the safe pattern:
# Python: secure JWT verification
import jwt
def verify_token(token, secret):
try:
claims = jwt.decode(
token,
secret,
algorithms=["HS256"], # MUST specify expected algorithm
audience="myapp", # MUST check audience
issuer="https://auth.example.com", # MUST check issuer
options={
"require": ["exp", "iat", "sub"], # MUST require critical claims
}
)
return claims
except jwt.ExpiredSignatureError:
raise Exception("Token expired")
except jwt.InvalidAudienceError:
raise Exception("Wrong audience")
except jwt.InvalidIssuerError:
raise Exception("Wrong issuer")
except jwt.InvalidTokenError:
raise Exception("Invalid token")
algorithms=[...] explicitly: Many libraries will default to accepting whatever algorithm is in the token header if you don't specify. This is the root cause of the alg:none and algorithm confusion attacks below. Pin the algorithm your application uses — never trust the token's self-declared algorithm.4. Security Vulnerabilities
4.1 The alg:none Attack
RFC 7519 defines an algorithm called none — no signature. Some JWT libraries, when the alg header says none, skip signature verification entirely. An attacker can forge a token:
// Attacker forges a token with alg:none
const header = Buffer.from('{"alg":"none","typ":"JWT"}').toString('base64url');
const payload = Buffer.from('{"sub":"admin","role":"superuser"}').toString('base64url');
const forgedToken = header + "." + payload + "."; // empty signature
If the server uses a library that doesn't explicitly check the algorithm, it accepts this token as valid with no signature at all.
algorithms=["HS256"] (or whichever you use) to the verification function. If none is not in your allowed list, the library will reject it. Never use a library that defaults to accepting none.4.2 Algorithm Confusion Attack (RS256 → HS256)
If your server accepts tokens signed with RS256 (asymmetric) and the attacker can trick it into using HS256 (symmetric) with the public key as the HMAC secret, they can forge tokens:
- Server expects RS256, has the RSA public key for verification.
- Attacker sends a token with
alg: HS256and signs it using HMAC-SHA256 with the public key as the secret. - If the server's library uses the
algheader to choose the verification method, it tries HMAC-SHA256 verification using the public key — which the attacker also knows. - Signature matches — forged token accepted.
algorithms=["RS256"] only. Never let the token tell you which algorithm to use. Additionally, don't treat RSA public keys as HMAC secrets — this is a library design flaw, but pinning the algorithm defends against it.4.3 Key ID (kid) Injection
The kid header parameter helps the server pick the right key for verification. If the server uses kid to construct a file path or database query, it can be vulnerable to path traversal or SQL injection:
// Malicious header with path traversal
{"alg":"HS256","kid":"../../dev/null"}
If the server loads the verification key from /keys/{kid}.pem, this reads /dev/null — an empty file. The attacker can then sign tokens with an empty HMAC key.
kid as untrusted input. Validate it against a whitelist of allowed key IDs, don't use it in file paths or SQL queries, and use a constant string mapping if possible.5. Token Storage and Lifecycle
Where to Store JWTs on the Client
| Storage | XSS Safe | CSRF Safe | Recommended |
|---|---|---|---|
| localStorage | No (JS can read) | Yes | Only for non-sensitive tokens |
| sessionStorage | No | Yes | Same as localStorage, cleared on tab close |
| HttpOnly Cookie | Yes (JS can't read) | No (needs CSRF token) | Recommended for web apps |
| Memory (variable) | Yes | Yes | SPAs with silent refresh |
oidc-client-ts and Auth0's SDK.Access Token vs Refresh Token
| Property | Access Token | Refresh Token |
|---|---|---|
| Lifetime | 5-30 minutes | 7-30 days |
| Purpose | Authenticate API requests | Get new access tokens |
| Storage | Memory / localStorage | HttpOnly cookie / server-side |
| Sent to | Every API request (Authorization header) | Token endpoint only |
| Revocation | Wait for expiration | Immediate (server-side revocation list) |
exp to hours or days for access tokens.6. JavaScript: Decode and Verify
// Browser: decode JWT (no verification — for debugging only)
function decodeJWT(token) {
const [headerB64, payloadB64, signatureB64] = token.split(".");
const decode = (s) => JSON.parse(atob(s.replace(/-/g, "+").replace(/_/g, "/")));
return {
header: decode(headerB64),
payload: decode(payloadB64),
signature: signatureB64,
// Signature is binary — show as hex
signatureHex: atob(signatureB64.replace(/-/g, "+").replace(/_/g, "/"))
.split("").map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join("")
};
}
// Browser: verify HS256 using Web Crypto API
async function verifyHS256(token, secret) {
const [headerB64, payloadB64, signatureB64] = token.split(".");
const data = new TextEncoder().encode(headerB64 + "." + payloadB64);
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["verify"]
);
const signature = Uint8Array.from(
atob(signatureB64.replace(/-/g, "+").replace(/_/g, "/")),
c => c.charCodeAt(0)
);
return crypto.subtle.verify("HMAC", key, signature, data);
}
alg:none.7. Common Pitfalls Checklist
| Pitfall | Risk | Fix |
|---|---|---|
| Not pinning algorithm | alg:none / algorithm confusion | Always pass algorithms=["HS256"] to verify |
| Putting secrets in payload | Anyone can read (Base64, not encrypted) | Use JWE or keep sensitive data server-side |
| Long-lived access tokens | Stolen tokens valid for hours/days | 5-30 min expiry + refresh token |
Not checking exp | Expired tokens accepted | Enable verify_exp: true (default in most libs) |
Not checking aud / iss | Cross-service token reuse | Always specify expected audience and issuer |
| Storing in localStorage | XSS can steal tokens | Use HttpOnly cookie + refresh token pattern |
Using kid in file paths | Path traversal to load wrong key | Whitelist key IDs, don't interpolate into paths |
8. FAQ
What is the difference between HS256 and RS256 in JWT?
HS256 uses HMAC-SHA256 with a shared secret that both the issuer and verifier must know. RS256 uses RSA with a private key for signing and a public key for verification. HS256 is simpler but requires sharing the secret. RS256 allows verification with only the public key, so third parties can verify tokens without the signing key.
Can JWT be encrypted instead of just signed?
Yes. JWT has two specifications: JWS (JSON Web Signature) which signs but does not encrypt, and JWE (JSON Web Encryption) which encrypts the token. Standard JWT tokens are JWS (the header.payload.signature format). If you need confidentiality (hiding claims from the client), use JWE instead.
What is the JWT alg:none attack?
The alg:none attack exploits JWT implementations that accept the algorithm header value 'none', which means no signature was applied. An attacker sets the header to {"alg":"none"} and sends any payload without a valid signature. If the server doesn't explicitly check the algorithm, it accepts the forged token. Always pin the expected algorithm on the server side.
How long should a JWT token live?
Access tokens should be short-lived: 5 to 30 minutes. This limits the window if a token is stolen. Use a refresh token (longer-lived, stored more securely) to obtain new access tokens without re-authentication. Never make access tokens long-lived (hours or days) as this increases the impact of theft.