← All modules
Protocols·28 min

JSON Web Tokens & API authentication

JWTs put a signed claim in every request header. They're everywhere in modern APIs — and a few well-known implementation mistakes keep recurring.

Developer / EngineerSecurity Architect

Structure

A JSON Web Token (JWT) is three base64url-encoded segments separated by dots: a header (naming the signing algorithm), a payload (the claims — arbitrary data such as user ID and expiry), and a signature over the first two segments. The signature can be produced with a symmetric HMAC (HS256) or an asymmetric algorithm like RSA or ECDSA (RS256, ES256).

Anatomy of a JWT

Header (base64url)

{"alg": "HS256", "typ": "JWT"} — names the signing algorithm.

Payload (base64url)

{"sub": "user123", "exp": 1735689600, ...} — the claims. Encoded, not encrypted.

Signature

HMAC or RSA/ECDSA signature over "header.payload", proving neither was altered.

signature=HMAC-SHA256(base64url(header) ∥ "." ∥ base64url(payload), secret)\text{signature} = \mathrm{HMAC\text{-}SHA256}(\text{base64url(header)} \,\|\, \texttt{"."} \,\|\, \text{base64url(payload)},\ \text{secret})

A real token, decoded

Here's an actual HS256-signed JWT encoding the claims {"sub": "1234567890", "name": "Alice", "iat": 1516239022}, signed with a symmetric secret. Each of the three segments below decodes with ordinary base64url — paste the first two into any base64 decoder and you'll get readable JSON back; only the third segment (the signature) is opaque, because it's the output of an HMAC, not an encoding of anything.

A real HS256 token, segment by segment

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

Decodes to {"alg":"HS256","typ":"JWT"}

eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNTE2MjM5MDIyfQ

Decodes to {"sub":"1234567890","name":"Alice","iat":1516239022}

gJh7jdj0jULbD61KFeWRh9Ux05MSQMND99uj_GbqN_k

HMAC-SHA256 over the first two segments, using the server's secret key

What the signature does and doesn't guarantee

Verifying a JWT's signature confirms the claims haven't been altered since signing and that they were signed by a holder of the corresponding key — it says nothing about whether the token has since been revoked or is still meant to be valid, which is why expiry (exp) claims and short lifetimes matter. The payload is only encoded, not encrypted: anyone can base64-decode it and read the claims, so secrets never belong there.

This is worth proving to yourself directly, since it's the single most common JWT misconception: base64url is not encryption, it's just an encoding, and reversing it takes no key at all.

Practice

Decode this JWT payload segment (base64url — swap '-' for '+' and '_' for '/' if you're using a standard base64 tool, then decode) and report the numeric value of its "exp" claim: eyJzdWIiOiI5MDAxIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzAwMDAwMDAwfQ

JWTs vs. opaque session tokens

The alternative to a JWT is an opaque session token: a random string that means nothing on its own, looked up in a server-side database on every request. The trade-off between them is really a trade-off about where state lives.

Opaque session token

  • •Server looks up session state in a database on every request
  • •Revoking access is instant — just delete the server-side record
  • •Doesn't scale as easily across independent services without a shared session store

JWT

  • •Self-contained — any service holding the public key (or shared secret) can verify it with no database lookup
  • •Scales well across microservices with no shared state
  • •Can't be revoked before its expiry without extra infrastructure (a blocklist, short lifetimes, etc.)

Revocation: the problem JWTs don't solve

The "can't be revoked before expiry" line in the comparison above is worth taking seriously, because it's a real operational gap, not a footnote: once a JWT is issued, it stays valid until its exp claim says otherwise, no matter what happens on the server afterward — a stolen laptop, a fired employee, a compromised API key all leave a window where the already-issued token still works, because there's no server-side record to delete the way there is for an opaque session token.

The standard real-world fix doesn't abandon JWTs — it limits the blast radius. Issue a short-lived JWT access token (commonly 5–15 minutes) for the actual API calls, paired with a long-lived opaque refresh token that is looked up server-side, exactly like a traditional session token, whenever the client needs a new access token. Revoking access now means revoking the refresh token — instant, just like an opaque token — while the worst an attacker can do with a stolen access token is use it until its own short expiry runs out.

Practice

An application issues 15-minute JWT access tokens alongside revocable refresh tokens. A user's device is reported stolen and its refresh token is revoked immediately. In the worst case, for how many more minutes could an attacker who already has a valid, unexpired access token keep using it?

Key rotation: the kid header and JWKS

Production signing keys get rotated periodically, for the same reasons any key does — but a verifier needs to know which of possibly several active public keys produced a given token, especially during the overlap window while an old key is still valid for already-issued tokens and a new key is signing fresh ones. The kid (Key ID) field in a JWT's header names exactly which key was used.

Verifiers resolve that kid against a JWKS (JSON Web Key Set) — a JSON document, typically published at a well-known endpoint, listing every currently valid public key by its kid. Rotating a signing key becomes: publish the new public key in the JWKS alongside the old one, start signing new tokens with the new key, and only remove the old public key from the JWKS once every token it ever signed has expired — seamless rotation with zero downtime and no already-issued token ever breaking early.

Go deeperThe "alg: none" attack: an unsigned token treated as trusted⌄

Before algorithm confusion, an even simpler flaw made the same rounds: JWT's header can legally specify "alg": "none", meaning no signature at all — a legitimate feature for specific narrow cases where a token's integrity is already guaranteed some other way. Some early library implementations, when asked to merely "verify" a token, would honor whatever algorithm the header claimed, including none — silently treating a completely unsigned token as valid.

The forgery is trivial once that flaw exists: take any legitimate token, base64url-decode the header and payload, edit the payload however you like (escalate a role, change a user ID), set the header's alg back to "none", and submit the result with an empty third segment. A vulnerable verifier, trusting the header's own claim about which algorithm to use, never notices nothing was actually signed.

token=base64url(header)+"."+base64url(payload)+"."(empty signature segment)\text{token} = \text{base64url(header)} + "." + \text{base64url(payload)} + "."\quad \text{(empty signature segment)}

Practice

Decode this JWT header segment and report its "alg" value: eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0

Go deeperAlgorithm confusion: turning a public key into an HMAC secret⌄

A more subtle attack targets servers that support both RS256 (asymmetric) and HS256 (symmetric) verification. If the verifier trusts the algorithm named in the token's own header rather than pinning the algorithm it expects, an attacker can take a server's public RSA key (which is, by design, public) and use it as the secret for an HS256-signed forged token. A verifier that blindly follows the header's "alg": "HS256" will compute the HMAC using that public key as the secret — and the attacker, who also has that public key, can compute the exact same HMAC.

Algorithm confusion attack

  1. 1

    Attacker obtains the server's public key

    RSA/ECDSA public keys used for RS256/ES256 verification are, by design, not secret.

  2. 2

    Attacker crafts a forged token

    They write whatever claims they want, set the header to "alg": "HS256", and sign it with HMAC using the public key as the HMAC secret.

  3. 3

    Vulnerable server verifies

    If the server reads "alg" from the token and uses HS256 verification with the same public key value, the forged signature checks out.

  4. 4

    The fix

    A correctly implemented verifier pins the expected algorithm itself and rejects any token that doesn't match — never trusting the header's own claim.

Knowledge check

Test what you just learned →

10 quick questions, with an explanation for every answer.

Up next

Passkeys & WebAuthn →

A passkey is what you get when you replace a password with a public-key challenge-response protocol. It fixes reuse, phishing, and breaches in one move.