Skip to content

Developer Security

JSON Web Tokens (JWT): A Deep Technical Dive for 2026

Published April 20, 2026 · 16 min read · By FastTool Editorial

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:

  1. 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.
  2. 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:

ClaimNameMeaningTypical value
issIssuerWho minted the tokenURL of your auth server
subSubjectWho the token is aboutOpaque user ID — not email
audAudienceWho the token is intended forAPI resource identifier
expExpirationUnix timestamp after which token is invalid15-min offset for access tokens
nbfNot beforeUnix timestamp before which token is invalidUsually same as iat
iatIssued atWhen the token was createdUnix timestamp at mint time
jtiJWT IDUnique token identifierUUID, 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.

AlgorithmTypeSig sizeKey sizeRelative speedBest use
HS256Symmetric32 B32 B secretFastestSingle-service monolith
RS256Asymmetric256 B2048-4096 bitSlow sign, fast verifyOIDC, legacy interop
ES256Asymmetric64 B256 bit curveFastWhen library doesn't support EdDSA
EdDSA (Ed25519)Asymmetric64 B256 bit curveFastest asymmetricGreenfield 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:

  1. Hard-code the expected algorithm at the verify call site. Do not read alg from the header and pass it to the verifier. Pass the expected value explicitly. This is the single most important rule.
  2. Verify the signature against the expected key. For JWKS, match the kid from the header to a key in your JWKS and use that key. Validate kid as a string before using it anywhere.
  3. Check exp against current time. Reject expired tokens. Allow a small clock skew (30-60 seconds) if your infrastructure has multiple servers.
  4. Check nbf against current time. Reject tokens that are not yet valid.
  5. Check iss against your expected issuer. A token issued by a different auth server should not be accepted.
  6. Check aud against your service identifier. A token minted for another audience should not be accepted. This prevents token reuse across services.
  7. Check jti against a replay cache if you need replay protection. Optional but recommended for high-value operations.
  8. 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:

PropertySession cookiesJWT
StateOn server (Redis, DB, memory)In the token itself
RevocationInstant — delete session rowRequires a blocklist or short TTL
ScaleSession store becomes a bottleneckStateless, horizontal scale is easy
Cross-serviceRequires shared session storeAny service with the public key verifies
Token sizeOpaque ID (~30 bytes)JSON + signature (500-1500 bytes typical)
Change user claimsUpdate the session rowWait for expiry or blocklist
Implementation complexityLowerHigher (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:

  1. User authenticates. Server issues an access token (5-15 min expiry) and a refresh token (hours to days).
  2. Client sends access token with every API call. API verifies signature and expiry, proceeds.
  3. When access token expires, client sends refresh token to /refresh endpoint.
  4. Server validates refresh token, issues a fresh access token and a fresh refresh token, invalidates the old refresh token.
  5. 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.