← All guides

JSON Web Tokens explained

What is inside a JWT, why anyone can read it, how the signature works, and the mistakes that make tokens unsafe.

A JSON Web Token, or JWT, is a small signed statement. A server writes down a few facts, such as “this is user 123 and this is valid for an hour”, signs them, and hands the result to a client. The client sends it back with later requests, and any server that can check the signature can trust the facts without looking anything up.

That is the whole idea. Most of the trouble with JWTs comes from misunderstanding two parts of it: what the signature protects, and what it does not.

Three parts separated by dots

Here is a complete token. It is an example made for this page, signed with the throwaway secret not-a-real-secret, and it is not valid anywhere.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyLTEyMyIsImF1ZCI6ImV4YW1wbGUtYXBpIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDAsInJvbGUiOiJlZGl0b3IifQ.mC3bWEcurJYR6Q6eA_HKY2G3tQ3YK4a3clOncjKH77A

The two dots split it into a header, a payload and a signature. The first part decodes to the header, which says how the token was signed:

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

The second part decodes to the payload, which holds the facts:

{
  "iss": "https://auth.example.com",
  "sub": "user-123",
  "aud": "example-api",
  "iat": 1700000000,
  "exp": 1700003600,
  "role": "editor"
}

The third part is the signature: raw bytes computed from the first two parts and a key. It doesn't decode to anything readable.

Encoded is not encrypted

Each part is written in base64url, a variant of base64 that uses - and _ in place of + and / and drops the trailing = padding so the token is safe to put in a URL or a header. Base64url is a way of spelling bytes with ordinary characters. It involves no key and hides nothing.

Anyone who holds the token can read the header and payload. Paste the example into the JWT decoder and you will see exactly the two objects above, along with the issue and expiry times as dates and a label saying the token has expired. The decoder shows the signature but does not verify it, because verifying needs the key. It runs entirely in your browser. You can also paste just the first part of the example, up to the first dot, into the base64 tab of the encode and decode tool and get the header back.

What the signature gives you is integrity. Change one character of the payload, say editor to admin, and the signature no longer matches. A server that checks it will reject the token. A server that doesn't check it will believe whatever the payload says.

The standard claims

The entries in the payload are called claims. You can add any you like, such as role above. A handful have defined meanings:

  • iss (issuer): who created the token.
  • sub (subject): who or what the token is about, usually a user ID.
  • aud (audience): which service the token is meant for. A service should reject tokens addressed to someone else.
  • exp (expiration): the moment after which the token must not be accepted.
  • iat (issued at): when the token was created.
  • nbf (not before): the moment before which the token must not be accepted.

The three time claims are numbers of seconds since the start of 1970 in UTC, not milliseconds. In the example, exp is 3,600 more than iat, so the token was good for one hour.

Two kinds of signature

The alg value in the header names the signing method. There are two families.

HMAC algorithms, such as HS256, use one shared secret. The same secret creates the signature and checks it. This is simple and fast, and it suits a single application that issues and verifies its own tokens. The catch is that every service able to verify a token is also able to forge one.

Public-key algorithms, such as RS256 (RSA) and ES256 (elliptic curve), use a key pair. The issuer signs with a private key that never leaves it. Everyone else verifies with the matching public key, which can be published openly. This suits a login service whose tokens are consumed by many other services, since none of them can mint tokens of their own.

The mistakes that bite

Putting secrets in the payload

The payload is readable by the user, by anything that logs the request, and by anyone who intercepts the token. Keep it to identifiers and permissions. Passwords, API keys and personal data do not belong there.

Decoding without verifying

Most JWT libraries offer one function that decodes a token and another that verifies it. Using the first where the second was needed produces code that works in every test and accepts any forged token. Verification also means checking the claims: reject an expired token, and reject one whose issuer or audience is not what you expect.

Accepting alg: none

The specification allows unsigned tokens, marked with none as the algorithm and an empty signature. A verifier that honors this will accept a token anyone can write by hand. Unsigned tokens should be refused outright.

Trusting the header to choose the algorithm

The header is part of the token, so the attacker controls it. If your server expects RS256 but will run whatever alg names, an attacker can switch the header to HS256 and sign the token using your public key as the HMAC secret. The public key is public, so the forgery verifies. Decide which algorithm and key you accept in your own configuration and reject everything else.

No expiry, or a very long one

A token without exp is valid until the signing key changes. If it leaks, it stays useful to whoever found it. Keep access tokens short-lived, minutes rather than days.

Storing tokens where scripts can read them

A token kept in the browser's local storage can be read by any script running on the page, including one injected through a cross-site scripting flaw. A cookie marked HttpOnly is not readable by scripts, which removes that route. Cookies are sent automatically, so they need protection against cross-site request forgery in return, for instance the SameSite attribute. The guide on HTTP security headers covers the headers that limit what an injected script can do.

Forgetting that tokens cannot be recalled

The appeal of a JWT is that a server can accept it without asking anyone. The cost is that nobody can take it back. Logging out, changing a password or disabling an account does nothing to a token that has already been issued; it stays valid until it expires. The usual answers are short-lived access tokens paired with a longer-lived refresh token that the server can revoke, or a server-side list of revoked token IDs that is checked on each request. Both bring back some of the lookups JWTs were meant to avoid. If you need instant revocation everywhere, an ordinary server-side session may be the simpler tool.

Reading a token safely

Decoding a token is the quickest way to debug an authentication problem: you can see at a glance whether it has expired, who issued it and which audience it names. Remember that a live token is a credential. Whoever holds it can act as the user until it expires. Prefer tokens from a test environment, or ones that have already expired, and avoid pasting production tokens into any website, including this one.