JWTs are everywhere in modern web development.
Your API probably uses them. Your OAuth flow almost certainly does. If you've ever implemented user authentication, you've dealt with a token that looks something like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJXaWxsaWFtIiwiaWF0IjoxNzExMTIzMjAwLCJleHAiOjE3MTExMjY4MDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Three sections separated by dots. Each one Base64-encoded. The whole thing looks like noise until you know what's inside — and once you do, it makes complete sense.
This guide covers what JWTs actually are, how they're structured, what can go wrong, and how to debug them when something breaks.
What is a JWT?
JWT stands for JSON Web Token. It's a compact, self-contained way to represent information securely between two parties. The key word is self-contained — a JWT carries all the information needed to verify it without needing to look anything up in a database.
This is the core tradeoff that makes JWTs useful and also dangerous if misused.
Traditional session-based authentication works like this: the server stores a session record, gives the user a session ID, and every request looks up that session ID in the database to get the user's identity. Simple but requires database lookups on every request.
JWT-based authentication works differently: the server creates a token containing the user's identity and signs it cryptographically. The user stores that token and sends it with every request. The server verifies the signature without hitting the database. Fast but the token can't be revoked until it expires.
The three parts of a JWT
Every JWT has three parts separated by dots:
Part 1 — The Header
The header is a Base64-encoded JSON object that describes the token. It always contains the algorithm used to sign the token and the token type:
{ "alg": "HS256", "typ": "JWT" }
The algorithm field tells you how the signature was created. Common values are HS256 (HMAC-SHA256, symmetric — same key signs and verifies), RS256 (RSA-SHA256, asymmetric — private key signs, public key verifies), and ES256 (ECDSA, also asymmetric but smaller keys).
Part 2 — The Payload
The payload contains the claims — statements about the user and the token itself. There are registered claims (standardized), public claims, and private claims.
{ "sub": "user_123", // subject — who the token is about "name": "William", // custom claim "role": "admin", // custom claim "iat": 1711123200, // issued at — unix timestamp "exp": 1711126800 // expires at — unix timestamp }
The most important registered claims are sub (subject — typically a user ID), iat (issued at — when the token was created), exp (expiration — when the token stops being valid), iss (issuer — who created the token), and aud (audience — who the token is intended for).
Important: The payload is Base64-encoded, not encrypted. Anyone who has the token can decode and read the payload without a key. Never put sensitive information like passwords, credit card numbers, or private keys in a JWT payload.
Part 3 — The Signature
The signature is what makes a JWT trustworthy. It's created by taking the encoded header and payload, joining them with a dot, and running them through the signing algorithm with a secret key:
HMACSHA256( base64url(header) + "." + base64url(payload), secret_key )
When your server receives a JWT, it recalculates the signature using the same key and algorithm. If the result matches the signature in the token, the token is valid. If they don't match, the token has been tampered with and must be rejected.
This is why the signature proves the token hasn't been modified — you can't change the payload without invalidating the signature, and you can't forge a new signature without the secret key.
How authentication with JWTs actually works
The flow is straightforward once you see it laid out:
1. User sends credentials (username + password) to /login
2. Server verifies credentials, creates JWT:
payload = { sub: "user_123", role: "admin", exp: now + 1hour }
token = sign(header + "." + payload, SECRET_KEY)
3. Server returns token to client
4. Client stores token (localStorage or httpOnly cookie)
5. Client sends token with every request:
Authorization: Bearer eyJhbGci...
6. Server verifies signature on every request
— no database lookup needed
— if valid: process request
— if invalid or expired: return 401
Common JWT mistakes and how to fix them
1. Token expired
The most common JWT error. The exp claim is a Unix timestamp. When the current time is past that timestamp, the token is invalid.
{ "sub": "user_123", "exp": 1711123200 // March 22, 2026 — already in the past }
The fix is to decode the token and check the exp field before using it. If expired, redirect to login or refresh the token. A good approach is short-lived access tokens (15 minutes to 1 hour) paired with longer-lived refresh tokens.
2. Wrong algorithm — the alg:none attack
This is a serious security vulnerability. Some early JWT libraries accepted tokens with "alg": "none" in the header, meaning no signature was required. An attacker could forge any token they wanted.
Security rule: Always specify and validate the expected algorithm in your JWT library. Never accept tokens with "alg": "none". Most modern libraries handle this by default but always check.
3. HS256 vs RS256 confusion
HS256 uses a single shared secret — the same key both creates and verifies the signature. This means your entire backend needs to know the secret. If you have multiple services, they all share it.
RS256 uses a key pair — a private key to sign and a public key to verify. Your auth service keeps the private key. Every other service only needs the public key. This is much better for distributed systems because a compromised service can't create new tokens, only verify them.
Use HS256 for simple single-server setups. Use RS256 for microservices and anything with multiple verification points.
4. Storing tokens in localStorage
Storing JWTs in localStorage is convenient but exposes them to XSS attacks. Any JavaScript running on your page — including third-party scripts — can read localStorage.
The safer approach for web apps is httpOnly cookies. These can't be accessed by JavaScript at all, which eliminates the XSS risk. The tradeoff is that you need to handle CSRF protection instead.
5. Not validating the audience claim
The aud claim specifies which service the token is intended for. If you have multiple services and don't validate audience, a token issued for one service could be used to access another. Always set and validate the audience claim in multi-service architectures.
How to debug a JWT
When a JWT isn't working, the fastest way to debug is to decode it and inspect the claims directly. You're looking for:
- Is the exp in the future? (Convert Unix timestamp to a readable date)
- Is the iss (issuer) what your server expects?
- Is the aud (audience) correct for this service?
- Does the sub match a real user in your system?
- Is the alg what you expect?
You can decode the header and payload yourself since they're just Base64 — but a dedicated tool is much faster.
Decode JWTs instantly in your browser
Paste any JWT token and see the decoded header, payload, and claims — including expiry status. Free, private, no data leaves your machine.
JWTs are not sessions
This is the most important conceptual point. Because JWTs are self-contained and verified without a database lookup, you cannot invalidate a JWT before it expires.
If a user logs out, changes their password, or gets their account suspended — their old JWT is still valid until it expires. The server has no way to revoke it without either:
- Keeping a blocklist of revoked tokens (now you need a database lookup, which defeats the point)
- Using very short expiry times (minutes) with a refresh token flow
- Using a hybrid approach where the JWT is validated against a lightweight session store
For most applications, short-lived access tokens (15 minutes) with refresh tokens is the right answer. The access token is a JWT for fast validation. The refresh token is stored server-side and can be invalidated immediately if needed.
A note on "stateless" authentication
You'll often hear JWT described as enabling "stateless" authentication. This is true in a narrow sense — the token carries its own state. But truly stateless auth means you can never log someone out, never invalidate a compromised token, and never change permissions until the token expires.
Almost every real production system that uses JWTs has some server-side state — a refresh token store, a blocklist, or at minimum a way to rotate secrets. "Stateless" is a useful default, not a hard requirement.
Build for the use case you have, not the architecture pattern you read about.
Quick reference
sub — subject (who the token is about, usually user ID) iss — issuer (who created the token) aud — audience (who should accept this token) exp — expiration (Unix timestamp, reject after this) iat — issued at (Unix timestamp, when created) nbf — not before (Unix timestamp, reject before this) jti — JWT ID (unique ID for this token, used for revocation)
HS256 — HMAC-SHA256, symmetric, single shared secret HS384 — HMAC-SHA384, symmetric, stronger HS512 — HMAC-SHA512, symmetric, strongest symmetric RS256 — RSA-SHA256, asymmetric, best for microservices RS384 — RSA-SHA384, asymmetric ES256 — ECDSA-SHA256, asymmetric, smaller keys than RSA none — no signature — NEVER ACCEPT THIS
If you're looking at a JWT you don't recognize, paste it into the debugger and read the claims. The header tells you how it was signed. The payload tells you what it contains and when it expires. The signature confirms nobody has tampered with it.
JWTs are not magic. They're a signed JSON object with an expiry date. Once you see them that way, they stop being confusing.
Try the DevCrate JWT Debugger
Decode any JWT instantly. See the header, payload, all claims, expiry status, and algorithm — all in your browser, completely private.