A JWT is three base64url-encoded JSON objects joined by dots. Understand the structure, signing, and the security pitfalls that trip developers up.
A JSON Web Token (JWT) is a compact, URL-safe way to transmit claims between two parties. It is used almost universally for API authentication โ when a user logs in, the server issues a JWT; the client stores it and sends it with every subsequent request. The server verifies the token's signature without looking anything up in a database. Understanding exactly how JWTs work โ and where they go wrong โ is essential for any developer building an API.
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNzE1MzM2MDAwLCJleHAiOjE3MTUzMzk2MDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Three base64url-encoded sections, separated by dots: header.payload.signature.
Decode the first section and you get a JSON object describing the token type and signing algorithm:
{
"alg": "HS256",
"typ": "JWT"
}
Common algorithms: HS256 (HMAC-SHA256, symmetric โ one shared secret), RS256 (RSA-SHA256, asymmetric โ private key signs, public key verifies).
The payload carries the actual data โ called claims:
{
"sub": "1234567890",
"name": "Alice",
"email": "alice@example.com",
"role": "admin",
"iat": 1715336000,
"exp": 1715339600,
"iss": "https://auth.example.com",
"aud": "https://api.example.com"
}
Standard registered claims:
sub โ subject (usually a user ID)iat โ issued at (Unix timestamp)exp โ expiration time (Unix timestamp)iss โ issuer (who created the token)aud โ audience (who should accept the token)nbf โ not before (token is invalid before this time)The signature is computed over the encoded header and payload using the algorithm and secret:
HMACSHA256(
base64url(header) + "." + base64url(payload),
secret
)
The signature cannot be forged without the secret. If the payload is tampered with, the signature will not match and the token is rejected. Note: the payload is encoded, not encrypted โ anyone can read it by base64-decoding it.
To inspect a token's payload for debugging (do not rely on this for authorization):
// JavaScript โ decode without verifying signature
function decodeJWT(token) {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('Invalid JWT');
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(atob(payload));
}
const claims = decodeJWT('eyJhbGci...');
console.log(claims.sub); // user ID
console.log(new Date(claims.exp * 1000)); // expiry date
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET; // store in env, never hardcode
// Sign a token (expires in 1 hour)
const token = jwt.sign(
{ sub: user.id, name: user.name, role: user.role },
SECRET,
{ expiresIn: '1h', issuer: 'https://auth.example.com' }
);
// Verify and decode
try {
const payload = jwt.verify(token, SECRET, {
issuer: 'https://auth.example.com'
});
console.log(payload.sub);
} catch (err) {
// TokenExpiredError, JsonWebTokenError, NotBeforeError
console.error('Invalid token:', err.message);
}
import jwt
from datetime import datetime, timedelta, timezone
SECRET = 'your-secret-key'
# Sign
payload = {
'sub': str(user.id),
'name': user.name,
'iat': datetime.now(timezone.utc),
'exp': datetime.now(timezone.utc) + timedelta(hours=1)
}
token = jwt.encode(payload, SECRET, algorithm='HS256')
# Verify
try:
decoded = jwt.decode(token, SECRET, algorithms=['HS256'])
print(decoded['sub'])
except jwt.ExpiredSignatureError:
print('Token expired')
except jwt.InvalidTokenError as e:
print(f'Invalid token: {e}')
| Algorithm | Type | Signs with | Verifies with | Best for |
|---|---|---|---|---|
| HS256 | Symmetric | Shared secret | Same secret | Single-service apps where server signs and verifies |
| RS256 | Asymmetric | Private key | Public key | Microservices โ auth server signs, multiple services verify without the private key |
| ES256 | Asymmetric (ECDSA) | Private key | Public key | Same as RS256 but smaller key size |
Early JWT libraries accepted tokens with "alg": "none" and no signature, treating them as valid. Always explicitly specify which algorithms you accept when verifying โ never pass an empty or wildcard list:
// WRONG โ accepts any algorithm including "none"
jwt.verify(token, secret);
// CORRECT โ whitelist the expected algorithm
jwt.verify(token, secret, { algorithms: ['HS256'] });
Storing a JWT in localStorage makes it readable by any JavaScript on the page, including injected scripts (XSS). Storing it in an httpOnly cookie means JavaScript cannot read it, but you must also set SameSite=Strict or SameSite=Lax to prevent CSRF. For most applications, httpOnly cookies are safer.
A stolen JWT cannot be invalidated (JWTs are stateless). Short-lived access tokens (15 minutes to 1 hour) combined with longer-lived refresh tokens minimize the damage window. The refresh token is stored securely and exchanged for a new access token when needed.
Always verify iss (issuer) and aud (audience) in addition to the signature and expiry. A token issued by one service should not be accepted by another service even if the signature is valid.
The payload is base64-encoded, not encrypted โ anyone with the token can read it. Never put passwords, credit card numbers, social security numbers, or other sensitive PII in the payload. Store only non-sensitive identifiers and permissions.
Here is how a typical stateless login system works end-to-end using JWTs:
POST /auth/login.httpOnly cookie.Authorization: Bearer <access_token>.POST /auth/refresh to get a new access token.Because JWTs cannot be revoked before they expire, a short access token lifetime limits the damage from a stolen token. The refresh token handles silent re-authentication:
const jwt = require('jsonwebtoken');
const ACCESS_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_SECRET = process.env.REFRESH_TOKEN_SECRET;
function issueTokens(userId, role) {
const accessToken = jwt.sign(
{ sub: userId, role },
ACCESS_SECRET,
{ expiresIn: '15m', issuer: 'https://auth.example.com' }
);
const refreshToken = jwt.sign(
{ sub: userId },
REFRESH_SECRET,
{ expiresIn: '7d', issuer: 'https://auth.example.com' }
);
return { accessToken, refreshToken };
}
// Refresh endpoint
app.post('/auth/refresh', (req, res) => {
const token = req.cookies.refreshToken; // httpOnly cookie
if (!token) return res.status(401).json({ error: 'No refresh token' });
try {
const payload = jwt.verify(token, REFRESH_SECRET, {
issuer: 'https://auth.example.com'
});
// Optionally: check token is not in a revocation list
const { accessToken } = issueTokens(payload.sub, payload.role);
res.json({ accessToken });
} catch (err) {
res.status(403).json({ error: 'Invalid or expired refresh token' });
}
});
JWTs are stateless by design โ there is no built-in way to invalidate a token before it expires. These are the practical patterns used in production:
jti claim) in Redis or a database. On each refresh request, check whether the token ID has been revoked. Log out by adding the ID to the revocation list. This is the most common approach.tokenVersion integer on the user record in the database. Embed the version in the JWT. Verify it on each request. Increment the version to invalidate all existing tokens for that user (password reset, suspicious activity).| Mistake | Risk | Fix |
|---|---|---|
| Hardcoding the secret in source code | Secret leaked in git history | Load from environment variable or secrets manager |
| Using a weak or short secret for HS256 | Brute-forced offline | Use a 256-bit+ random secret; prefer RS256 for distributed systems |
Not verifying exp | Expired tokens accepted forever | Always verify โ most libraries do this by default |
Not verifying iss and aud | Token from another service accepted | Pass expected issuer and audience to the verify call |
| Storing sensitive data in payload | Payload readable by anyone with the token | Store only user ID and role; fetch sensitive data server-side |
| Storing access token in localStorage | Exposed to XSS attacks | Keep access token in memory; refresh token in httpOnly cookie |
Use the JSON Formatter Hub to format, validate, and fix your JSON right now โ free and fully browser-based.