The Three Parts
A JWT is three base64url-encoded strings separated by dots. That is it. No magic, no binary protocol. Just three chunks of text.
A real JWT token:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIEpvaG5zb24iLCJlbWFpbCI6ImFsaWNlQGV4YW1wbGUuY29tIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzA5MTIzMjAwLCJleHAiOjE3MDkxMjY4MDAsImlzcyI6ImF1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJhcGkuZXhhbXBsZS5jb20ifQ.4p8P1FGcaNaFxGAh7a8DcaEE_8GgT0vLspd82NYLxaM
Split on the dots and you get three parts:
Part 1 (Header): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Part 2 (Payload): eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIEpvaG5zb24iLC...
Part 3 (Signature): 4p8P1FGcaNaFxGAh7a8DcaEE_8GgT0vLspd82NYLxaM
Base64url Is Not Encryption
This is the most important thing to understand about JWTs. Base64url is an encoding, not encryption. It is reversible by anyone. No key required. No secret needed. Anyone who has the token can read the header and payload.
Decoding the header and payload in JavaScript:
function decodeJwtPart(part: string): object {
// base64url -> base64 (replace URL-safe chars)
const base64 = part.replace(/-/g, "+").replace(/_/g, "/");
// Decode and parse
const json = atob(base64);
return JSON.parse(json);
}
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIEpvaG5zb24iLCJlbWFpbCI6ImFsaWNlQGV4YW1wbGUuY29tIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzA5MTIzMjAwLCJleHAiOjE3MDkxMjY4MDAsImlzcyI6ImF1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJhcGkuZXhhbXBsZS5jb20ifQ.4p8P1FGcaNaFxGAh7a8DcaEE_8GgT0vLspd82NYLxaM";
const [header, payload] = token.split(".");
console.log(decodeJwtPart(header));
// { "alg": "HS256", "typ": "JWT" }
console.log(decodeJwtPart(payload));
// {
// "sub": "1234567890",
// "name": "Alice Johnson",
// "email": "alice@example.com",
// "role": "admin",
// "iat": 1709123200,
// "exp": 1709126800,
// "iss": "auth.example.com",
// "aud": "api.example.com"
// }That is why you should never put sensitive data in a JWT payload. No passwords, no credit card numbers, no SSNs. Anyone who intercepts the token (from browser DevTools, server logs, or a proxy) can read everything in it. The signature only proves the token was not tampered with. It does not hide the contents.
The Header
The header is usually boring. It tells the server which algorithm to use for verifying the signature.
Typical header:
{
"alg": "HS256", // HMAC with SHA-256
"typ": "JWT" // Token type
}Common algorithms you will see:
- HS256: Symmetric. Same secret key for signing and verifying. Simple, good for single-server setups.
- RS256: Asymmetric. Private key signs, public key verifies. Use this when multiple services need to verify tokens but only one service should issue them.
- ES256: Asymmetric with elliptic curves. Smaller signatures than RS256, same security properties.
- none: No signature. Never accept this in production. It has been the source of real CVEs where attackers set
alg: "none"to bypass signature verification entirely.
The Payload: Standard Claims
The payload is where the actual data lives. JWT defines a set of standard claim names (all optional), and you can add any custom claims you need.
Standard (registered) claims:
{
// "iss" (Issuer): who created this token
"iss": "auth.example.com",
// "sub" (Subject): who/what this token is about
// Usually a user ID
"sub": "user_abc123",
// "aud" (Audience): who this token is intended for
// Your API should reject tokens with the wrong audience
"aud": "api.example.com",
// "exp" (Expiration): Unix timestamp when token expires
// After this time, the token should be rejected
"exp": 1709126800,
// "iat" (Issued At): when this token was created
"iat": 1709123200,
// "nbf" (Not Before): token is not valid before this time
// Useful for tokens that should activate in the future
"nbf": 1709123200,
// "jti" (JWT ID): unique identifier for this specific token
// Useful for revocation (store revoked JTI values in a blocklist)
"jti": "550e8400-e29b-41d4-a716-446655440000"
}The three-letter names look cryptic, but they are intentionally short. JWTs travel in HTTP headers on every request, so keeping them compact matters. A few extra bytes per claim adds up at scale.
How Expiration Works
The exp claim is a Unix timestamp. When your server receives a JWT, it compares exp against the current time. If the token has expired, it gets rejected. No database lookup needed.
Checking expiration:
function isTokenExpired(token: string): boolean {
const [, payloadB64] = token.split(".");
const payload = JSON.parse(
atob(payloadB64.replace(/-/g, "+").replace(/_/g, "/"))
);
if (!payload.exp) {
// No expiration claim. Treat as expired (be strict).
return true;
}
// exp is in seconds, Date.now() is in milliseconds
const nowSeconds = Math.floor(Date.now() / 1000);
// Allow 30 seconds of clock skew between servers
const CLOCK_SKEW_SECONDS = 30;
return payload.exp < (nowSeconds - CLOCK_SKEW_SECONDS);
}
// Common token lifetimes:
// Access tokens: 15 minutes to 1 hour
// Refresh tokens: 7 to 30 days
// Short-lived access tokens limit the damage if a token is stolenThe clock skew tolerance is important. If your auth server and your API server have clocks that are a few seconds apart (common in distributed systems), a token might appear expired on one server but valid on another. A 30-second buffer prevents spurious rejections.
JWS vs JWE: Signed vs Encrypted
Most JWTs you encounter are technically JWS (JSON Web Signature) tokens. The payload is readable by anyone; the signature just proves it was not modified. This is sufficient for most authentication use cases: the server needs to verify the token is genuine, and the payload contains non-sensitive data like user IDs and roles.
JWE (JSON Web Encryption) tokens are different. The payload is actually encrypted, so only the intended recipient with the decryption key can read the contents. JWE tokens have five parts separated by dots instead of three.
When to use which:
// JWS (what most people mean by "JWT") // Structure: header.payload.signature // Use when: payload is not sensitive // Examples: user ID, role, permissions, email // Anyone can READ the payload // Nobody can MODIFY the payload without the key // JWE (encrypted JWT) // Structure: header.encryptedKey.iv.ciphertext.tag // Use when: payload contains sensitive data // Examples: PII, session data you want to hide from // browser DevTools or proxy inspection // Nobody can READ or MODIFY the payload without the key
In practice, most applications use JWS and simply avoid putting sensitive data in the payload. If you find yourself wanting JWE because you need to hide the token contents from the client, consider whether a server-side session with an opaque session ID would be simpler.
Security Checklist
If you are implementing JWT auth, verify these things:
- Always validate the signature. Do not decode the payload and trust it without checking the signature first.
- Always check
exp. Tokens without expiration live forever if stolen. - Validate
issandaud. A token from your staging auth server should not be accepted by your production API. - Reject
alg: "none". Your JWT library should do this by default, but verify. - Use short lifetimes for access tokens. 15 minutes is a good default. Use refresh tokens for longer sessions.
- Store tokens securely in the browser. HttpOnly cookies are safer than localStorage for access tokens. localStorage is readable by any JavaScript on the page, including XSS payloads.
- Have a revocation strategy. JWTs are stateless by design, which means you cannot invalidate one without extra infrastructure. A short-lived token plus a blocklist of revoked
jtivalues is the common approach.