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:

  1. Signature valid, with the expected key.
  2. 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”.
  3. exp is in the future (allow a little clock skew, under a minute). Keep server clocks synced. See chrony.
  4. iss and aud match 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 jti values (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.

  • API
  • Security
  • Web Development