A JSON Web Token (JWT) is a compact, signed string that carries claims, such as “this is user 42, with role editor, valid until 10:30”. APIs use JWTs as access tokens because any server holding the right key can verify them without a database lookup. That’s convenient, and easy to get subtly wrong. Here’s how they work and how to use them safely.
Structure
A JWT is three Base64URL-encoded parts separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJlZGl0b3IiLCJleHAiOjE3NjA0MzAwMDB9.4dQ…signature…
header payload signature
Header:
{ "alg": "HS256", "typ": "JWT" }
Payload (claims):
{
"sub": "42",
"role": "editor",
"iss": "https://auth.example.com",
"aud": "api.example.com",
"iat": 1760426400,
"exp": 1760430000
}
| Claim | Meaning |
|---|---|
sub |
Subject: who the token is about (user ID) |
iss |
Issuer: who created it |
aud |
Audience: which service it’s for |
exp |
Expiry (Unix time) |
iat / nbf |
Issued at / not valid before |
jti |
Unique token ID (useful for revocation lists) |
Signature: computed over the header and payload with a key. Changing a single character of the payload invalidates it.
Important: the payload is encoded, not encrypted. Anyone can decode it, for example by pasting it into a JWT debugger. Never put secrets or sensitive personal data in a JWT. (JWE, encrypted JWTs, exist but are rarely needed.)
Signing algorithms
| Algorithm | Type | Use when |
|---|---|---|
| HS256 | Symmetric (shared secret) | The same service issues and verifies tokens |
| RS256 / ES256 / EdDSA | Asymmetric (private key signs, public key verifies) | An auth server issues tokens, and many services verify them |
With asymmetric keys, verifying services only need the public key, often fetched from a JWKS endpoint (/.well-known/jwks.json). That’s how OpenID Connect providers work. See OAuth 2.0 explained.
HS256 secrets must be long and random (at least 32 random bytes): openssl rand -base64 48.
Validate properly, every time
A token is only trustworthy after all these checks:
- Signature valid, with the expected key.
- Algorithm is the one you expect. Pin it in your verify call, and never accept
alg: none. Don’t let the token’s header choose the algorithm, or attackers can switch RS256 to HS256 and sign with the public key as the “secret”. expis in the future (allow a little clock skew, under a minute). Keep server clocks synced. See chrony.issandaudmatch your expected values. A token for another service mustn’t work on yours.
Use a maintained library (jose, PyJWT, jsonwebtoken, golang-jwt) and pass the expected algorithm, issuer and audience explicitly:
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'));
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'api.example.com',
algorithms: ['RS256'],
});
Lifetimes, refresh tokens and revocation
A JWT stays valid until it expires. You can’t easily revoke it without extra state. Common patterns:
- Short-lived access tokens (5–15 minutes) plus refresh tokens (longer, stored server-side, revocable). When the access token expires, the client exchanges the refresh token for a new one.
- Rotate refresh tokens on each use, and detect reuse (a sign of theft).
- For immediate revocation, keep a denylist of
jtivalues (in Redis, for example) until they would have expired, or increment a per-user “token version” stored in the database.
Where to store tokens in the browser
| Storage | XSS risk | CSRF risk | Notes |
|---|---|---|---|
localStorage |
High: any injected script can read it | None | Simple, but one XSS leaks tokens |
| HttpOnly, Secure, SameSite cookie | Script can’t read it | Needs CSRF protection (SameSite helps) | Usually the safer choice for web apps |
| Memory only | Lower | None | Lost on reload. Pair with a refresh cookie |
For server-rendered and same-site apps, plain session cookies are often simpler and safer than JWTs. JWTs shine for APIs used by many services and clients.
Common JWT mistakes
- Trusting the payload without verifying the signature (decoding isn’t verifying).
- Accepting
alg: none, or letting the header pick the algorithm. - Weak HS256 secrets (
secret, the app name). These can be brute-forced offline. - Very long-lived access tokens (days or weeks) with no revocation.
- Sensitive data in the payload.
- Not checking
aud, so tokens for one service work on another. - Tokens in URLs (they end up in logs).
Debugging
Decode a token locally, without sending it to a website:
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null; echo
Related: API security best practices and CORS explained.