JSON Web Tokens (JWT) explained
Open in the interactive app: practice, quiz, mock interview →In one minute
- A JSON Web Token is a set of JSON claims plus a signature, packed into three base64url segments separated by dots:
header.payload.signature. - The signature proves who issued the claims and that nobody changed them. It does not hide them: anyone holding the token can read the payload.
- A JWT is only as safe as its validation. The consumer must pin the algorithm, find the right key, check the signature, then check
exp,nbf,issandaud, every time. - Most JWT incidents come from the header being trusted:
alg: none, RSA-to-HMAC confusion, and attacker-supplied key URLs. - Key rotation through a published key set (JWKS) and short lifetimes are what make JWTs operable at scale.
How it works
Three segments
A JWT in its usual form (a JWS compact serialization) looks like eyJhbGciOi... . eyJzdWIiOi... . MEUCIQ.... Each segment is base64url-encoded without padding, so the token is safe in URLs, headers and cookies.
The header says how the token is protected, for example {"alg":"ES256","kid":"2026-10","typ":"at+jwt"}. The payload holds claims: registered ones such as iss (issuer), sub (subject), aud (audience), exp (expiry), nbf (not before), iat (issued at) and jti (a unique ID), plus any private claims such as tenant or scope. The signature is computed over the exact ASCII bytes base64url(header) + "." + base64url(payload), which is called the signing input.
The verifier never re-serializes the JSON. It signs and checks the encoded segments as received, which sidesteps the problem that the same JSON object can be written in many byte-different ways.
Symmetric and asymmetric signatures
With HS256 the issuer and every verifier share one secret, and the signature is HMAC-SHA256 over the signing input. It is fast and simple, but any service that can verify can also mint tokens, so it fits only when issuer and verifier are the same trust domain.
With RS256, ES256 or Ed25519 the issuer signs with a private key and publishes the public key. Any number of services can verify, and none can forge. Older documents write Ed25519 as EdDSA, but RFC 9864 (October 2025) deprecated that identifier because it does not say which curve to use, and registered the fully specified Ed25519 and Ed448 in its place (RFC 9864). Libraries will accept both for years, so pin the one your issuer actually emits. This is what lets an identity provider issue tokens that hundreds of services and third parties check independently.
What a JWT is for, and what it is not
JWTs fit wherever a claim must cross a trust boundary and be checked without calling home: OAuth access tokens, OpenID Connect ID tokens, service-to-service credentials, CI jobs proving their identity to a cloud provider, signed invitation links. They fit poorly as long-lived browser sessions, because they cannot be revoked before exp without adding back the lookup they were meant to remove. If the payload must be secret, the encrypted variant (JWE, five segments) exists, but most systems simply keep secrets out of the payload.
The problem shape: trust but verify
A JWT moves trust from a network call to a signature check, and the same shape appears in S3 presigned URLs, Stripe's signed webhooks (a signature plus a timestamp tolerance against replay) and signed software packages. Each time, the rules are the same: the verifier, not the message, decides which key and algorithm are acceptable; the signature covers every field that matters; and a time window limits replay.
Down the stack
Layer 1The contract
RFC 7519 defines the claims; RFC 7515 defines how they are signed; RFC 8725 lists what a careful consumer must do (RFC 8725). Its replacement, draft-ietf-oauth-rfc8725bis, was with the IESG in 2026 and adds attacks seen since 2020, such as alg blocklists bypassed with "noNE", JWE decompression bombs and SSRF through jku and x5u (draft-ietf-oauth-rfc8725bis). A validator promises to accept a token only if all of these hold: the algorithm is on an allowlist and matches the key it selects; the signature over the signing input verifies with that key; now < exp + leeway; now + leeway >= nbf when nbf is present; iss equals the expected issuer; and aud (a string or an array) contains this service's identifier. Leeway is typically 30 to 120 seconds to absorb clock skew; RFC 7519 says "usually no more than a few minutes" (RFC 7519 §4.1.4). RFC 9068 requires JWT access tokens to carry typ: at+jwt and requires resource servers to reject any other value, so an ID token cannot be replayed as an access token (RFC 9068 §4). Nothing in the format promises revocation, confidentiality or single use; those need jti tracking, JWE, or a server-side store.
Layer 2The mechanism
HS256 is HMAC per RFC 2104: pad the key to SHA-256's 64-byte block, compute SHA256((K xor opad) || SHA256((K xor ipad) || message)) with ipad = 0x36 and opad = 0x5c repeated, and compare the 32-byte result with a constant-time comparison so timing does not leak how many bytes matched. RS256 is an RSASSA-PKCS1-v1_5 signature, 256 bytes for a 2048-bit key. ES256 is ECDSA on P-256, and JOSE encodes it as the raw 64 bytes r || s, not the DER structure most crypto libraries emit; mixing the two is a classic interoperability bug.
Verification keys are published as a JSON Web Key Set at the issuer's jwks_uri: entries like {"kty":"EC","crv":"P-256","kid":"2026-10","x":"...","y":"..."}. The verifier reads kid from the header, selects the key from a cached set, and takes the algorithm from the key, not from the token. That last rule closes the famous 2015 bugs: libraries that honoured alg: none accepted unsigned tokens, and libraries that let alg: HS256 select HMAC accepted tokens "signed" with the issuer's public RSA key used as an HMAC secret (Auth0, 2015). Header fields like jku and x5u, which point at a key URL, must be ignored or pinned, or an attacker simply supplies their own key. In 2022, CVE-2022-21449 showed the bottom layer can fail too: Java 15 to 18 accepted ECDSA signatures with r = s = 0 as valid for any message (Neil Madden). Even a correct library leaves one check to you: that the key is allowed to sign this kind of token for this issuer, which is the check Microsoft's mail system skipped in 2023 (see In the wild).
Layer 3The machine
Base64 inflates by 4/3, so a 300-byte JSON payload becomes 400 characters. An ES256 signature is 86 characters, an RS256 signature 342, so a modest RS256 access token is 800 to 1,200 bytes and travels on every request. Verification cost, roughly, on one modern core: HMAC-SHA256 about 1 us for a short input; RSA-2048 verify 20 to 40 us (the public exponent 65537 makes it cheap); Ed25519 verify about 50 us; P-256 verify 60 to 100 us. Signing is the asymmetric part: an RSA-2048 signature costs about 1 ms, which only the issuer pays. Expiry checks read the local wall clock, so a host whose NTP sync has drifted by two minutes rejects fresh tokens or accepts stale ones, and the JWKS fetch is an HTTPS call that must be cached (commonly 5 to 60 minutes) and rate-limited, or an unknown kid in a flood of junk tokens becomes a flood of outbound requests.
Why it's built this way
- Claims must pass through URLs, headers and cookies, so the format is compact and base64url-encoded.
- JSON serialization is not canonical, so the signature covers the encoded bytes and nobody re-serializes.
- Issuers need to change algorithms and keys over time, so the header names
algandkid. - A header the attacker writes cannot be trusted to choose security, so modern validators pin the algorithm to the key and use the header only as a lookup hint.
- Tokens cannot be recalled, so lifetimes are short and keys rotate through a cached, published key set.
Diagram
flowchart TD
T["token a.b.c"] --> P{"3 segments, valid JSON?"}
P -->|no| M[reject: malformed]
P -->|yes| K{"kid in cached JWKS?"}
K -->|no| U[reject: unknown key]
K -->|yes| A{"header alg == key alg?"}
A -->|no| BA[reject: bad alg]
A -->|yes| S{"signature over a.b ok?"}
S -->|no| BS[reject: bad signature]
S -->|yes| C{"exp, nbf, iss, aud ok?"}
C -->|no| BC[reject: claims]
C -->|yes| OK[accept claims]Worked example
An identity provider issues ES256 access tokens that live 15 minutes. Forty services cache its JWKS for 10 minutes. The security team wants to rotate the signing key every 90 days without a single failed request.
The timeline follows from the two numbers. At T0 the issuer publishes the new public key in the JWKS alongside the old one but keeps signing with the old key. Every verifier has refetched within 10 minutes, so from T0 + 10 minutes everyone knows both keys. Add margin for a stuck cache and switch signing to the new key at T0 + 1 hour. Tokens signed with the old key keep arriving for up to 15 minutes after the switch, plus leeway, so the old key stays published until at least T0 + 1 hour 17 minutes; in practice, a day later. Skipping the first step (sign with the new key immediately) produces up to 10 minutes of unknown kid failures on every service, which at 25,000 requests per second is up to 15 million rejected calls.
Cost check: 25,000 verifications per second at about 80 us each is 2 CPU-seconds per second, two cores spread across the fleet. Each token is about 900 bytes, so headers add about 22 MB/s of ingress. Both are acceptable; neither would be if tokens bloated to 8 KB with a full group list, which also trips the 8 KB header limits of many proxies; nginx, for example, answers 400 when one header field exceeds its default 8 KB buffer (nginx docs).
In the wild
- Microsoft, 2023: a consumer key signed enterprise tokens. The Storm-0558 actor used a Microsoft account (consumer) signing key to forge tokens that Exchange Online accepted for enterprise mailboxes, reaching about 25 organizations from 15 May until customers noticed on 16 June (MSRC, July 2023). The signature was valid. The bug was that the mail system's developers assumed the shared library checked issuer and key scope, and it only checked the signature. Microsoft shipped libraries that validate scope automatically (MSRC, September 2023).
- Java, 2022: "psychic signatures". The ECDSA verifier rewritten in Java 15 skipped the check that
randsare non-zero, so an all-zero signature verified against any key and any message: JWTs, OIDC ID tokens, SAML assertions and WebAuthn messages included. Reported in November 2021 and fixed in the April 2022 critical patch update; Oracle scored it 7.5, while ForgeRock, where the reporter worked, rated it 10 (Neil Madden).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| HS256 (shared secret) | Fastest, tiny signature, simple | Every verifier can forge; secret must be distributed | Issuer and verifier are one service or one team |
| RS256 | Widely supported, fast verify | 342-character signature, slow signing, large keys | Interop with older libraries and providers |
| ES256 / Ed25519 | Short signatures, strong small keys | Slower verify than RSA; ES256 encoding pitfalls | New systems issuing many tokens |
| JWT vs opaque token + introspection | No lookup per request, works offline | No early revocation, claims visible and stale | Many verifiers, high QPS, short lifetimes |
| JWE (encrypted) | Payload confidential in transit and at rest in logs | Complex, larger, fewer libraries | Claims must stay secret from the client |
Pitfalls in production
- Decoding instead of verifying. Calling a library's
decodewith verification off, "just to read the user ID", trusts attacker input. Make the verified path the only path, and lint for the unsafe call. - Trusting the header's
alg. Pass an explicit allowlist and bind each key to one algorithm, sononeand HMAC-with-a-public-key tokens are rejected before any crypto runs. Compare exactly: a case-insensitive blocklist ofnoneletsnOnEthrough. - Skipping
aud. A token minted for the low-risk reporting service is accepted by the payments service. Every service must check that its own identifier is inaud. - Secrets in the payload. Base64url is encoding, not encryption; emails, internal IDs and roles end up in browser history, logs and analytics. Put only what verifiers need, and nothing sensitive.
- Rotation without overlap. Signing with a new
kidbefore verifiers have it fails every request for one JWKS cache period. Publish first, sign later, retire last. - Unbounded JWKS refetch. Refetching on every unknown
kidlets junk tokens drive outbound traffic. Refetch at most once every few seconds and cap the cache size.
Leadership lens
- Mandate one shared, reviewed validation library or gateway filter; JWT bugs come from every team writing its own five lines of checks.
- Ask in design review: what is the token lifetime, what is the revocation story, and which claims are trusted for authorization?
- Treat key rotation as a routine, automated operation with a runbook and an alarm on
unknown kidrates, not a yearly event someone remembers. - Decide early whether tokens may reach third parties; that choice forces asymmetric keys, audience discipline and a public JWKS endpoint with its own SLO.
Check yourself
Why must the verifier compute the signature over the received base64url segments instead of re-encoding the parsed JSON?
Show answer
JSON has many byte-different encodings of the same object (key order, whitespace, escapes). Re-encoding could produce different bytes than the issuer signed, so verification would fail or, worse, accept something the issuer never signed.
How does the RSA-to-HMAC algorithm confusion attack work, and what single design rule prevents it?
Show answer
The attacker sets
alg: HS256and signs with the issuer's public RSA key as the HMAC secret; a library that lets the header choose the algorithm verifies it with that same public key. Binding each key to one algorithm, chosen by the verifier, stops it.A token has
expof 10:00:00 and the service allows 60 seconds of leeway. Is it accepted at 10:00:45? At 10:01:00?Show answer
Accepted at 10:00:45, because that is before
exp + 60s. Rejected at 10:01:00, since the rule isnow < exp + leeway.Why is
typ: at+jwtuseful when the same issuer also signs ID tokens?Show answer
Both are signed by the same key with similar claims, so without a type check an ID token handed to a client could be replayed to an API as an access token.
Sources and further study
Primary
- RFC 9864: Fully-Specified Algorithms for JOSE and COSE (2025): why
EdDSAis deprecated in favour ofEd25519andEd448. - draft-ietf-oauth-rfc8725bis: the pending update to the JWT best practices, with the attacks found since 2020.
- RFC 7517: JSON Web Key: the JWK and JWK Set formats behind every
jwks_uri.
Systems docs
- nginx: large_client_header_buffers: the header-size limit oversized tokens hit first.
- OpenID Connect Core 1.0, ID Token validation: the most widely deployed JWT validation rules, now also ISO/IEC 26131:2024.
Books
- Building Microservices (2nd ed.), Sam Newman, Ch 11 "Security": JSON Web Tokens for passing user identity between services, and the confused deputy problem.
- Building Secure and Reliable Systems, Adkins et al. (Google, 2020), Ch 5 "Design for Least Privilege": why credentials should be narrowly scoped and short-lived (read free).
Other curricula
- OWASP JSON Web Token Cheat Sheet: a checklist of the same validation and storage rules from the attacker's side.
- Azure Architecture Center: Valet Key pattern: signed, time-limited, narrowly scoped grants as a general design pattern.
Watch
- What Is JWT and Why Should You Use JWT (Web Dev Simplified): the structure and signing flow worked through in code.
- Session Vs JWT: The Differences You May Not Know! (ByteByteGo): where JWTs fit compared with server-side sessions.
Free resources
- RFC 7519: JSON Web Token: the registered claims and their exact processing rules.
- RFC 7515: JSON Web Signature: the signing input, compact serialization and header parameters.
- RFC 8725: JWT Best Current Practices: the attacks seen in the wild and the validation rules that stop them.
- RFC 9068: JWT Profile for OAuth 2.0 Access Tokens: required claims and the
at+jwttype for access tokens. - Critical vulnerabilities in JSON Web Token libraries (Auth0): the original write-up of the
noneand RSA-to-HMAC confusion bugs. - RFC 2104: HMAC: the construction behind HS256, short and readable.