Developer Security
JSON Web Tokens (JWT): A Deep Technical Dive for 2026
The JWT spec is 30 pages. The average production JWT bug is shorter — a missing exp claim, a verifier that accepts alg: none, a kid header treated as a file path. And yet every year brings a fresh CVE about a JSON Web Token verifier getting the trust boundary wrong. The cryptography is not the problem. The problem is that JWT sits at the seam between "code I wrote" and "data I received," and the failures happen at that seam.
This guide is the long-form technical reference. We cover what a JWT actually is on the wire, the RFCs you should have open in another tab, the header and payload structure, the signing algorithms and when to use each, the refresh rotation pattern that makes long-lived sessions survivable, and the vulnerabilities that are still shipping to production in 2026. Bring a coffee.
The RFC stack
JWT is not one document. It is a family of specs that slot together into the JOSE (JSON Object Signing and Encryption) stack. Before writing any JWT code, know what lives where:
- RFC 7519 — JWT itself. The claim format, the registered claims, the processing rules.
- RFC 7515 — JSON Web Signature (JWS). How signatures attach to payloads. Most JWTs in the wild are JWS.
- RFC 7516 — JSON Web Encryption (JWE). For when you need the payload to be confidential, not just tamper-evident.
- RFC 7518 — JSON Web Algorithms (JWA). The list of algorithm identifiers and what they mean.
- RFC 7517 — JSON Web Key (JWK) and JWK Sets. Key distribution format.
- RFC 8725 — JWT Best Current Practices. The short, practical companion to 7519. Read this one second.
RFC 8725 is the unsung hero of the stack. It was published in 2020 specifically because implementations kept making the same mistakes. If you read only two RFCs from this list, make them 7519 and 8725.
Anatomy of a token
A signed JWT is three base64url-encoded strings joined by dots: header.payload.signature. An example token (formatted for readability):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NSIsIm5hbWUiOiJBbGljZSIsImlhdCI6MTcxMzYwMDAwMCwiZXhwIjoxNzEzNjAzNjAwfQ.
3cP0gEqb3LZJwHq4U7vXbA_m3rP5fK8YqZ1vL_oDm2k
Decode the first segment and you get the header:
{ "alg": "HS256", "typ": "JWT" }
Decode the second and you get the payload (the claims):
{
"sub": "12345",
"name": "Alice",
"iat": 1713600000,
"exp": 1713603600
}
The third segment is the HMAC-SHA256 of base64url(header) + "." + base64url(payload) using the shared secret. If you have access to the secret you can verify the signature; if you only have the public key (for RS256 etc.) you can verify but not mint.
Two critical properties of this structure:
- The header and payload are base64url-encoded, not encrypted. Anyone with the token can read every claim. If you put sensitive data in there, it is effectively public.
- The signature covers
header.payload. Modifying either byte flips the signature check. This is the tamper-evidence property. Whether it actually prevents tampering depends entirely on the verifier doing the check correctly.
To inspect a token without running any code, JWT Decoder parses the three segments and shows header and payload. JWT Debugger additionally verifies the signature given a secret or public key. Both run in the browser with no upload.
Registered claims and what they mean
RFC 7519 defines seven registered claim names, all optional, all meaningful. Use them consistently across your system:
| Claim | Name | Meaning | Typical value |
|---|---|---|---|
iss | Issuer | Who minted the token | URL of your auth server |
sub | Subject | Who the token is about | Opaque user ID — not email |
aud | Audience | Who the token is intended for | API resource identifier |
exp | Expiration | Unix timestamp after which token is invalid | 15-min offset for access tokens |
nbf | Not before | Unix timestamp before which token is invalid | Usually same as iat |
iat | Issued at | When the token was created | Unix timestamp at mint time |
jti | JWT ID | Unique token identifier | UUID, for replay detection |
A well-formed access token looks like:
{
"iss": "https://auth.example.com",
"sub": "usr_01H8XM9...",
"aud": "https://api.example.com",
"exp": 1713603600,
"nbf": 1713600000,
"iat": 1713600000,
"jti": "3a9c8e22-d6c8-4b2e-ad91-17b4c0c12ab7",
"scope": "read:documents write:documents"
}
Two pragmatic notes. First, put only stable user IDs in sub. Emails and usernames change; database user IDs do not. Second, jti is the basis for any revocation or replay-detection scheme you might bolt on later. Generate it with a UUIDv4 or UUIDv7 at mint time — UUID Generator produces both.
Signing algorithms: HS256, RS256, ES256, EdDSA
The alg header field names the algorithm. RFC 7518 enumerates valid values. The four that matter in 2026:
HS256 — HMAC with SHA-256
Symmetric. Same secret on both sides. Fast. Small signatures (32 bytes). Use when the signer and verifier are the same service (e.g., one backend that issues and accepts tokens) or when you can distribute the shared secret over a secure channel.
Risk: anyone with the secret can mint valid tokens. In a multi-service architecture, HS256 means sharing the HMAC secret across every service that verifies. If one service is compromised, every service is compromised. For anything beyond a single monolith, use asymmetric signing.
Generate HS256 signatures and verify them with HMAC Generator.
RS256 — RSA with SHA-256
Asymmetric. Private key signs, public key verifies. Wide library support. The default for OpenID Connect and most OAuth flows. Keys are 2048 or 4096 bits, signatures are 256 or 512 bytes.
Use when you need verifiers that do not hold the private key — microservices architectures, third-party API consumers, JWKS-distributed public keys. Signing is relatively slow (a few milliseconds per token on modern hardware) but verification is fast.
ES256 — ECDSA over P-256
Asymmetric, elliptic-curve variant. Smaller keys and signatures than RSA (64-byte signatures, 32-byte public keys). Faster signing. Requires a fresh random nonce on every sign operation; a nonce reuse or bad RNG leaks the private key. History has CVEs where buggy RNGs leaked ECDSA keys.
EdDSA (Ed25519) — Edwards-curve Digital Signature Algorithm
Modern asymmetric. Deterministic (no random nonce needed during signing, which eliminates the ECDSA nonce-reuse class of bugs). Fast. 64-byte signatures, 32-byte public keys. Side-channel resistant. The best modern default.
For greenfield 2026 work, EdDSA is the recommended choice. Library support has caught up — PyJWT, jose-jwt, node-jose, and Nimbus JOSE all support Ed25519. Use it unless you have a specific interop reason to choose RS256.
| Algorithm | Type | Sig size | Key size | Relative speed | Best use |
|---|---|---|---|---|---|
| HS256 | Symmetric | 32 B | 32 B secret | Fastest | Single-service monolith |
| RS256 | Asymmetric | 256 B | 2048-4096 bit | Slow sign, fast verify | OIDC, legacy interop |
| ES256 | Asymmetric | 64 B | 256 bit curve | Fast | When library doesn't support EdDSA |
| EdDSA (Ed25519) | Asymmetric | 64 B | 256 bit curve | Fastest asymmetric | Greenfield 2026 default |
The validation checklist
A correct JWT verifier runs every one of these checks. Skip any of them and you are building a vulnerability:
- Hard-code the expected algorithm at the verify call site. Do not read
algfrom the header and pass it to the verifier. Pass the expected value explicitly. This is the single most important rule. - Verify the signature against the expected key. For JWKS, match the
kidfrom the header to a key in your JWKS and use that key. Validatekidas a string before using it anywhere. - Check
expagainst current time. Reject expired tokens. Allow a small clock skew (30-60 seconds) if your infrastructure has multiple servers. - Check
nbfagainst current time. Reject tokens that are not yet valid. - Check
issagainst your expected issuer. A token issued by a different auth server should not be accepted. - Check
audagainst your service identifier. A token minted for another audience should not be accepted. This prevents token reuse across services. - Check
jtiagainst a replay cache if you need replay protection. Optional but recommended for high-value operations. - Enforce a maximum token size. A 50KB token is almost always an attack. Reject oversize tokens before parsing.
In code, the checks look like this (Node.js with jose, similar in every language):
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json')
);
async function verify(token) {
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ['EdDSA'], // hard-coded, not from header
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
maxTokenAge: '15m',
clockTolerance: '30s'
});
return payload;
}
The algorithms option is the critical line. Library defaults have been patched for years, but if you see code that passes algorithms read from the header, that is a bug.
Vulnerabilities that still ship
The alg: none accept (CVE-2015-9235 and descendants)
RFC 7518 defines an algorithm literally called none for unsigned tokens. The intent was for tokens protected by other means (e.g., TLS-bound claims). In practice it is a footgun: a verifier that trusts the header's algorithm field will skip signature verification when the attacker sends alg: none.
Every mainstream library has patched the default. The vulnerability still appears in custom wrappers, in code that does case-insensitive comparisons and misses NoNe or nONE, and in implementations that accept alg from an untrusted config. The fix is universal: hard-code the accepted algorithm set and never trust the header.
Algorithm confusion (HS256 vs RS256)
A service is configured to verify with RS256 and holds the public key. An attacker crafts a token with header alg: HS256 and signs it using the RSA public key bytes as an HMAC secret. A naive library that picks the algorithm from the header will "verify" the token: it treats the public key as an HMAC secret, recomputes the HMAC, and it matches because the attacker computed it the same way.
Same fix: pass algorithms: ['RS256'] at the verify call site. Never derive the algorithm from the token.
Key confusion via kid injection
The kid (key ID) header tells the verifier which key to use when multiple are in play. Some verifiers read kid and feed it into a lookup without sanitization: a filesystem path, a database query, a URL. Path traversal, SQL injection, and SSRF all follow.
Treat kid as untrusted input. If you use it for JWKS lookup, match against a fixed allowlist. Do not concatenate it into file paths or URLs.
SSRF via jku or x5u headers
The jku and x5u headers can contain URLs where the verifier fetches keys. An attacker-controlled URL turns your verifier into an SSRF gadget. RFC 8725 recommends a strict allowlist of permitted URLs. If you do not need these headers, disable them.
Missing exp claim
Tokens without expiration are valid forever. An attacker who gets one is permanently authenticated. Reject any token missing exp, even if your auth server "always sets it."
JWTs in query strings
URLs end up in server access logs, proxy logs, browser history, and Referer headers. A JWT in a query string leaks into all of them. Use Authorization: Bearer <token> headers or HTTP-only cookies. Never query strings.
Long-lived refresh tokens without rotation
A refresh token that is not rotated on use is a permanent credential. If it leaks, the attacker can generate access tokens indefinitely. Rotation is mandatory for any real refresh flow.
The OWASP JWT Cheat Sheet walks through each of these with language-specific examples. PortSwigger's Web Security Academy has interactive labs for algorithm confusion and kid injection.
JWT vs session cookies
The "JWT vs session" debate keeps flaring up on Hacker News because both are reasonable choices and the answer depends on architecture. Here is the honest comparison:
| Property | Session cookies | JWT |
|---|---|---|
| State | On server (Redis, DB, memory) | In the token itself |
| Revocation | Instant — delete session row | Requires a blocklist or short TTL |
| Scale | Session store becomes a bottleneck | Stateless, horizontal scale is easy |
| Cross-service | Requires shared session store | Any service with the public key verifies |
| Token size | Opaque ID (~30 bytes) | JSON + signature (500-1500 bytes typical) |
| Change user claims | Update the session row | Wait for expiry or blocklist |
| Implementation complexity | Lower | Higher (algorithm, keys, rotation) |
Rules of thumb:
- Single backend, web app, one domain → session cookies. Simpler, easier to revoke, lower error surface.
- Microservices with multiple independently-deployed verifiers → JWT. The stateless property pays off.
- Federated identity (OIDC, SSO) → JWT. The spec is built for this.
- Mobile app + web app sharing an API → JWT. Native clients handle bearer tokens well.
- Long-lived "remember me" sessions that need revocation on logout → session cookies or JWT + blocklist.
The mistake is using JWT because it is trendy when a session cookie would have solved the problem with fewer moving parts. Stytch and Okta have both written thoughtful posts on this tradeoff.
Refresh tokens done right
Short-lived access tokens plus longer-lived refresh tokens is the pattern that resolves the JWT revocation problem. The mechanics:
- User authenticates. Server issues an access token (5-15 min expiry) and a refresh token (hours to days).
- Client sends access token with every API call. API verifies signature and expiry, proceeds.
- When access token expires, client sends refresh token to
/refreshendpoint. - Server validates refresh token, issues a fresh access token and a fresh refresh token, invalidates the old refresh token.
- Client replaces both tokens and continues.
Step 4 is the critical one. Rotating the refresh token means the old value is no longer valid. If an attacker steals a refresh token and uses it before the legitimate client does, the legitimate client's next refresh attempt will fail. That failure is the compromise signal — revoke the session, log the user out, force re-authentication.
Storage matters:
- Refresh tokens go in HTTP-only, Secure, SameSite=Strict cookies. JavaScript cannot read them. XSS cannot steal them.
- Access tokens go in memory — a JavaScript variable, closure, or signed state atom. Never in localStorage. localStorage is readable by any XSS, which makes any single injection into a permanent credential leak.
Pseudocode for a refresh cycle:
// Client
async function apiCall(url, opts = {}) {
let res = await fetch(url, { ...opts, headers: { Authorization: `Bearer ${accessToken}` }});
if (res.status === 401) {
// refresh and retry
const refresh = await fetch('/auth/refresh', { method: 'POST', credentials: 'include' });
if (!refresh.ok) return redirectToLogin();
const data = await refresh.json();
accessToken = data.accessToken; // refresh cookie is reset server-side
res = await fetch(url, { ...opts, headers: { Authorization: `Bearer ${accessToken}` }});
}
return res;
}
Auth0, Okta, Stytch, and Clerk all implement this pattern. Roll your own only if you understand the rotation invariant.
When to use JWE for encryption
A JWS (the signed JWT we've been discussing) is tamper-evident but not confidential. Everyone who has the token can read the claims. If you need the payload to be unreadable to intermediaries, you want JWE.
JWE wraps the payload in encryption. The token is encrypted to the recipient's public key (or a shared symmetric key), so only the intended recipient can read the claims. The structure has five segments instead of three: header.encryptedKey.iv.ciphertext.authTag.
Use JWE when:
- The token passes through intermediaries you do not trust with claim content (third-party APIs, logging systems)
- The claims include sensitive data that should not be logged even accidentally
- You need defense-in-depth for tokens that also traverse TLS
Do not use JWE as a substitute for HTTPS. TLS handles transport-level confidentiality. JWE handles object-level confidentiality. They solve different problems.
For experimenting with JWE payloads, JWT Generator supports multiple algorithms and Encryption Tool handles AES-GCM primitives.
Reference implementations
Minting a JWT with Ed25519 in Node.js
import { SignJWT, generateKeyPair, exportJWK } from 'jose';
const { privateKey, publicKey } = await generateKeyPair('EdDSA', { crv: 'Ed25519' });
const jwt = await new SignJWT({ scope: 'read:docs' })
.setProtectedHeader({ alg: 'EdDSA' })
.setIssuer('https://auth.example.com')
.setAudience('https://api.example.com')
.setSubject('usr_01H8XM9')
.setIssuedAt()
.setExpirationTime('15m')
.setJti(crypto.randomUUID())
.sign(privateKey);
console.log(jwt);
console.log('JWK public key:', await exportJWK(publicKey));
Verifying with PyJWT in Python
import jwt
from jwt import PyJWKClient
jwks_client = PyJWKClient('https://auth.example.com/.well-known/jwks.json')
signing_key = jwks_client.get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=['EdDSA', 'RS256'], # explicit allowlist
issuer='https://auth.example.com',
audience='https://api.example.com',
leeway=30, # clock skew tolerance
options={'require': ['exp', 'iat', 'sub']}
)
Quick local inspection with curl
# Split header.payload.signature and base64url-decode each segment
TOKEN="eyJhbGciOi..."
HDR=$(echo $TOKEN | cut -d. -f1)
PLD=$(echo $TOKEN | cut -d. -f2)
echo $HDR | base64 -d 2>/dev/null | jq .
echo $PLD | base64 -d 2>/dev/null | jq .
For a no-install option, JWT Decoder does the same inspection in a browser tab without sending the token anywhere. JWT Debugger adds signature verification and algorithm inspection. JWT Generator mints tokens for local testing.
Supporting tools
The authentication stack touches adjacent primitives constantly. Useful tools: HMAC Generator for HS256 secrets and verification, Hash Generator for SHA-256 checks, UUID Generator for jti values, Base64 Encoder/Decoder for manual segment inspection, Bearer Token Generator for opaque random tokens, Password Generator for HMAC secret generation, Bcrypt Hash Generator for storing password verifiers, HTTP Security Headers for auditing the broader security surface, URL Encoder/Decoder for ensuring tokens do not get mangled in URLs, and API Tester for probing protected endpoints.
Related guides
For signing-specific depth, see JWT Security Deep Dive: Signing, Validation, Refresh. For the encoding layer, see Base64, JWT, URL Encoding: A Developer Reference. For the password side of authentication, see Modern Password Security Strategy: From NIST 800-63B to Passkeys.
FAQ
What are the relevant RFCs for JWT?
RFC 7519 (JWT), RFC 7515 (JWS signed form), RFC 7516 (JWE encrypted form), RFC 7518 (JWA algorithms), RFC 7517 (JWK keys), and RFC 8725 (Best Current Practices). Read 7519 and 8725 first.
Which JWT signing algorithm should I use in 2026?
EdDSA with Ed25519 is the strongest default for greenfield projects — fast, small, side-channel resistant, and deterministic so no nonce-generation bugs. RS256 is the default for OIDC interop. HS256 is fine for single-service monoliths where you control the secret lifecycle.
Is JWT safer than session cookies?
Neither is inherently safer. Session cookies are easier to revoke centrally and simpler to implement. JWTs scale statelessly and work across service boundaries without a shared session store. Pick based on architecture, not trend.
What is the alg:none vulnerability?
Some verifiers read the algorithm from the untrusted header. When an attacker sends alg: none, a naive verifier skips signature checking entirely. Fix by hard-coding the accepted algorithm set at the verify call site — never trust the header.
How long should a JWT live?
Access tokens: 5-15 minutes. Refresh tokens: hours to days, with mandatory rotation on each use. Longer access tokens mean larger blast radius on leak; rotation detects and limits refresh token compromise.
Where should I store JWTs in a browser app?
Refresh tokens in HTTP-only, Secure, SameSite=Strict cookies. Access tokens in memory (variable or closure). Never localStorage — it is readable by any XSS, which turns every injection into a permanent credential leak.
Closing thought
JWT is simple until you get it wrong, and wrong usually means the verify path trusts something it should not. The failures cluster in the same five or six places year after year — the algorithm field, the kid lookup, the missing exp, the refresh token without rotation, the token in localStorage. Write the validation checklist down, pin it to the auth service README, and audit the verify code against it whenever anything changes. The cryptography is the easy part. The discipline is the hard part.