JWT Explained: How JSON Web Tokens Work

If you've built or consumed a modern API, there's a very good chance JWT came up. JSON Web Tokens are the standard mechanism for stateless authentication in web applications, used everywhere from mobile apps to microservices. But a lot of developers use them without really understanding what's inside — and that leads to security mistakes. Let's break them down properly.

What Is a JWT?

A JWT is a compact, URL-safe string that encodes a JSON payload along with a cryptographic signature. The server creates a token when a user logs in and sends it to the client. The client sends this token with every subsequent request. The server can verify the token's authenticity without making a database lookup — that's the key advantage.

A JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNzE2MzA3MjAwLCJleHAiOjE3MTYzOTM2MDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Three parts separated by dots: header.payload.signature

Part 1: The Header

The header is a base64url-encoded JSON object that specifies the token type and signing algorithm:

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

Common algorithms:

  • HS256 — HMAC with SHA-256. Uses a shared secret key. Simple but both parties must share the secret.
  • RS256 — RSA with SHA-256. Uses a public/private key pair. The server signs with the private key; anyone can verify with the public key.
  • ES256 — ECDSA with SHA-256. Like RS256 but uses elliptic curves — smaller tokens, faster verification.

Part 2: The Payload (Claims)

The payload contains the data you want to transmit, called claims. Claims are just JSON key-value pairs. JWT defines a set of registered claim names:

{
  "iss": "https://auth.creesol.com",
  "sub": "user_42",
  "aud": "https://api.creesol.com",
  "exp": 1716393600,
  "iat": 1716307200,
  "nbf": 1716307200,
  "jti": "a3f8b2c1-d4e5-6789-0abc-def012345678",
  "name": "Alice",
  "email": "alice@example.com",
  "roles": ["admin"]
}
  • iss — Issuer: who created the token
  • sub — Subject: who the token is about (usually a user ID)
  • aud — Audience: who the token is intended for
  • exp — Expiration: Unix timestamp after which the token is invalid
  • iat — Issued At: when the token was created
  • nbf — Not Before: token is not valid before this time
  • jti — JWT ID: unique identifier, used to prevent replay attacks

You can add any custom claims you want alongside the registered ones. Keep the payload small — it's transmitted on every request.

Part 3: The Signature

The signature proves the token hasn't been tampered with. For HS256, it's computed as:

HMAC-SHA256(
  base64url(header) + "." + base64url(payload),
  secret_key
)

If an attacker modifies any part of the header or payload and resubmits the token, the signature won't match and the server will reject it. The key point: the signature provides integrity, not confidentiality. Anyone can decode the header and payload — they're just base64url encoded, not encrypted. Never put sensitive data in a JWT payload unless you're using JWE (JSON Web Encryption).

The Authentication Flow

  1. User submits credentials (username + password) to POST /auth/login
  2. Server verifies credentials against the database
  3. Server creates a JWT signed with its secret key, sets a short expiration (e.g., 15 minutes)
  4. Server also issues a refresh token (opaque, long-lived, stored in database)
  5. Client stores the access JWT in memory (not localStorage — more on this below)
  6. Client sends Authorization: Bearer <token> header on each API request
  7. Server verifies the signature and checks exp — no database lookup needed
  8. When the JWT expires, client uses the refresh token to get a new JWT

Security Pitfalls to Avoid

1. Don't store JWTs in localStorage

localStorage is accessible to any JavaScript on the page. If your site has an XSS vulnerability, an attacker can steal every token. Store access tokens in memory (a JavaScript variable) and refresh tokens in an HttpOnly cookie, which JavaScript cannot read.

2. Validate the algorithm

Early JWT libraries had a vulnerability where setting "alg": "none" in the header would bypass signature verification. Always explicitly specify which algorithms you accept and reject none.

// Node.js — explicitly allow only HS256
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });

3. Check all claims

Verify exp, iss, and aud on every request. A valid signature on an expired or wrong-audience token should still be rejected.

4. Keep tokens short-lived

Since JWTs are stateless, you can't invalidate them from the server (without maintaining a blocklist, which defeats the purpose). Short expiration times (5–15 minutes) limit the window of damage if a token is stolen.

5. Use RS256 for distributed systems

If multiple services need to verify tokens, RS256 is better: only your auth service holds the private key, while any service can verify using the public key. No shared secret to leak across services.

Need to inspect a JWT, decode its payload, or verify its structure?

Try the Free JWT Decoder

Frequently Asked Questions

Where should a JWT be stored — localStorage or a cookie?

Store access JWTs in memory (a JavaScript variable), not localStorage. localStorage is accessible to any JavaScript on the page, so an XSS attack can silently steal the token. Refresh tokens should be stored in an HttpOnly cookie, which JavaScript cannot read at all. If you must store a short-lived access token somewhere persistent, a Secure, HttpOnly, SameSite=Strict cookie is safer than localStorage.

What is the difference between JWT and traditional session tokens?

A traditional session token is an opaque random string stored in a server-side database — the server looks it up on every request. A JWT is self-contained: user data is encoded inside the token itself, and the server verifies the cryptographic signature without a database lookup. JWTs scale better horizontally since any server with the signing key can verify the token. The trade-off is that JWTs cannot be individually invalidated once issued without maintaining a server-side blocklist.

What happens when a JWT expires?

When a JWT's exp claim passes its Unix timestamp, the server should reject it with a 401 Unauthorized response. The client then uses a refresh token to obtain a new access token. The refresh token is typically a long-lived, opaque token stored in an HttpOnly cookie, sent to a dedicated /auth/refresh endpoint. The server validates the refresh token against its database and issues a new short-lived JWT.

Is a JWT encrypted, or just encoded?

A standard JWT (JWS — JSON Web Signature) is encoded, not encrypted. The header and payload are base64url encoded — anyone can decode them to read the contents. The signature proves the token has not been tampered with but does not hide the data. Never store passwords, credit card numbers, or secrets in a JWT payload. If you need confidentiality, use JWE (JSON Web Encryption), which is a separate standard.

How do you invalidate a JWT before it expires?

This is one of JWT's fundamental limitations. The main approaches are: (1) Keep JWTs short-lived (5–15 minutes) to minimise the exposure window. (2) Maintain a server-side blocklist of revoked jti (JWT ID) values in Redis — the server checks this on every request. (3) Use refresh token rotation: revoking the refresh token prevents new access tokens from being issued. Option 2 reintroduces server-side state but enables true instant revocation.