Engineering Atlas › System Design Fundamentals

JSON Web Tokens (JWT) explained

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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

  1. Claims must pass through URLs, headers and cookies, so the format is compact and base64url-encoded.
  2. JSON serialization is not canonical, so the signature covers the encoded bytes and nobody re-serializes.
  3. Issuers need to change algorithms and keys over time, so the header names alg and kid.
  4. 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.
  5. 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

Trade-offs

OptionStrengthsWeaknessesChoose when
HS256 (shared secret)Fastest, tiny signature, simpleEvery verifier can forge; secret must be distributedIssuer and verifier are one service or one team
RS256Widely supported, fast verify342-character signature, slow signing, large keysInterop with older libraries and providers
ES256 / Ed25519Short signatures, strong small keysSlower verify than RSA; ES256 encoding pitfallsNew systems issuing many tokens
JWT vs opaque token + introspectionNo lookup per request, works offlineNo early revocation, claims visible and staleMany verifiers, high QPS, short lifetimes
JWE (encrypted)Payload confidential in transit and at rest in logsComplex, larger, fewer librariesClaims must stay secret from the client

Pitfalls in production

Leadership lens

Check yourself

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

  2. How does the RSA-to-HMAC algorithm confusion attack work, and what single design rule prevents it?

    Show answer

    The attacker sets alg: HS256 and 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.

  3. A token has exp of 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 is now < exp + leeway.

  4. Why is typ: at+jwt useful 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

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →