What Is a JWT? JSON Web Tokens Explained With Examples
Key Takeaways
- • A JWT is a signed, self-contained token — three Base64Url parts separated by dots: header, payload, signature.
- • JWTs are signed, not encrypted — the payload is readable by anyone. Never put secrets in it.
- • The signature mathematically proves the token was issued by a trusted party and has not been modified.
- • Per a 2025 PortSwigger analysis, 28% of JWT-related security breaches stem from algorithm confusion attacks — always pin your accepted algorithm.
- • Use access tokens short (5–15 min) + refresh tokens long (7–30 days) stored in HttpOnly cookies.
The Most Common Misconception About JWTs
Every week on Stack Overflow, a developer stores a user's password in a JWT payload, ships it to production, and wonders why their app got owned. Here's the thing that trips people up: a JWT is not encrypted. It is signed. Those two words describe entirely different operations with entirely different security properties.
When you see a JWT like eyJhbGc..., you are looking at Base64Url-encoded text — the same encoding used to attach files to emails. Paste it into the Base64 decoder and you get readable JSON back in two seconds. The signature on a JWT does not hide data; it proves the token was issued by someone with the signing key and has not been tampered with since.
That distinction cleared up, JWTs are one of the most elegant solutions in modern auth: stateless, portable, and verifiable without a database round-trip. By 2026, the JWT standard (RFC 7519) underpins authentication for an estimated 85% of Fortune 500 APIs, according to an AppSec Master 2026 infrastructure analysis.
What Is a JWT Token?
A JSON Web Token (JWT) — pronounced "jot" — is an open standard (RFC 7519) that defines a compact, URL-safe format for securely transmitting claims between parties as a JSON object. A "claim" is a statement: "this token was issued for user 4521," or "this token expires at 1745000000."
The defining characteristic is self-containment. Unlike session tokens that require a server-side lookup to know who the user is, a JWT carries all necessary information inside itself. Any service with the right verification key can validate it — instantly and without network calls.
// A real JWT looks like this (line breaks added for readability):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiJ1c2VyXzQ1MjEiLCJuYW1lIjoiQWxpY2UiLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3MDk4NTIwMDAsImV4cCI6MTcwOTg1MjkwMH0
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
// Three parts separated by dots:
// Part 1 — Header: algorithm + token type
// Part 2 — Payload: the actual claims (user data, expiry, etc.)
// Part 3 — Signature: cryptographic proof of authenticityThe header decodes to {"alg":"HS256","typ":"JWT"}. The payload decodes to a JSON object with your claims. The signature is a hash computed over the header + payload using your secret key. Change one character in the payload, the signature no longer matches — the token is rejected.
Paste any JWT into our JWT Decoder to inspect its parts in real time — it runs entirely in your browser.
The Three Parts of a JWT, In Depth
1. Header
The header specifies how the token is signed. It is a JSON object with two standard fields: alg (algorithm) and typ (type). This JSON is then Base64Url-encoded to produce the first segment of the JWT.
// Header JSON:
{
"alg": "RS256", // RSA with SHA-256 — asymmetric key pair
"typ": "JWT"
}
// Base64Url-encoded → eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9
// Algorithm options:
// HS256 — HMAC-SHA256 (symmetric — same key signs and verifies)
// RS256 — RSA-SHA256 (asymmetric — private signs, public verifies)
// ES256 — ECDSA-P256 (asymmetric — smaller key than RSA, same security)
// EdDSA — Ed25519 (asymmetric — fastest verify, modern default)2. Payload (Claims)
The payload contains claims. RFC 7519 defines three categories:
- Registered claims: standardized names with agreed-upon meanings (
iss,sub,exp, etc.) - Public claims: collision-resistant names registered in the IANA JWT Claims Registry
- Private claims: application-specific data both parties agree on
{
// RFC 7519 registered claims
"iss": "https://auth.example.com", // issuer
"sub": "user_4521", // subject (user ID)
"aud": "https://api.example.com", // audience (intended recipient)
"exp": 1745852900, // expiration (Unix timestamp)
"nbf": 1745852000, // not valid before this time
"iat": 1745852000, // issued at
"jti": "a8f2c1d9-e3b7-4a10", // unique JWT ID (for revocation/replay protection)
// Private claims — your application-specific data
"role": "admin",
"tenant_id": "acme-corp",
"permissions": ["reports:read", "users:write"]
}| Claim | Name | Required? | What to put there |
|---|---|---|---|
| iss | Issuer | Recommended | Your auth server URL |
| sub | Subject | Yes | Unique, stable user ID |
| aud | Audience | Recommended | API base URL this token is for |
| exp | Expiration | Always | Unix timestamp, 5–15 min from now |
| iat | Issued At | Yes | Creation timestamp |
| jti | JWT ID | If using revocation | UUID v4 for blocklist lookups |
3. Signature
The signature is the cryptographic guarantee. It is computed over base64Url(header) + "." + base64Url(payload) using the algorithm declared in the header.
// HS256 (symmetric — one secret used for both sign and verify):
signature = HMAC-SHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secretKey
)
// RS256 (asymmetric — private key signs, public key verifies):
signature = RSA-SHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
privateKey
)
// To verify: check that the computed signature matches the token's third segment
// If the payload was modified → signature mismatch → token rejectedCreating and Verifying a JWT: Real Code
Most languages have a well-maintained JWT library. Here is the full create-and-verify cycle in Node.js using the jose library (the most RFC-compliant option in 2026):
import { SignJWT, jwtVerify, generateSecret } from 'jose'
// 1. Generate a signing secret (do this once, store in env var)
const secret = await generateSecret('HS256')
// 2. Sign a JWT (e.g., after user logs in)
async function createAccessToken(userId: string, role: string): Promise<string> {
return new SignJWT({ sub: userId, role })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer('https://auth.example.com')
.setAudience('https://api.example.com')
.setExpirationTime('15m') // 15 minutes
.sign(secret)
}
// 3. Verify a JWT on each request
async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, secret, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
algorithms: ['HS256'], // explicit allowlist — never allow 'none'
})
return payload // throws if invalid, expired, or tampered
}
// Usage in Express middleware:
app.use(async (req, res, next) => {
const authHeader = req.headers.authorization
if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ error: 'No token' })
try {
const token = authHeader.slice(7)
req.user = await verifyAccessToken(token)
next()
} catch (err) {
res.status(401).json({ error: 'Invalid or expired token' })
}
})For Python, the PyJWT library handles the same workflow:
import jwt
import datetime
import os
SECRET_KEY = os.environ['JWT_SECRET']
def create_access_token(user_id: str, role: str) -> str:
payload = {
'sub': user_id,
'role': role,
'iat': datetime.datetime.utcnow(),
'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=15),
'iss': 'https://auth.example.com',
'aud': 'https://api.example.com',
}
return jwt.encode(payload, SECRET_KEY, algorithm='HS256')
def verify_access_token(token: str) -> dict:
# PyJWT raises exceptions for expired/invalid/tampered tokens
return jwt.decode(
token,
SECRET_KEY,
algorithms=['HS256'], # explicit allowlist
audience='https://api.example.com',
issuer='https://auth.example.com',
)Signing Algorithms Compared: HS256 vs RS256 vs ES256
Your choice of signing algorithm determines who can verify tokens and how — a critical architectural decision. According to a 2025 Veracode scan analysis, over 15% of production JWT implementations use mismatched algorithm configurations, creating exploitable vulnerabilities.
| Algorithm | Type | Key Size | Best For | Weakness |
|---|---|---|---|---|
| HS256 | Symmetric | 32+ bytes | Monolith; single trusted service | Secret must be shared with every verifier |
| RS256 | Asymmetric | 2048-bit RSA | Microservices; JWKS endpoint | Slow sign; large key |
| ES256 | Asymmetric | 256-bit EC | Performance-sensitive APIs | Less widely supported in older libs |
| EdDSA | Asymmetric | 256-bit Ed25519 | Modern stacks, highest perf | Requires jose 4+ or equivalent |
For microservices, RS256 is the pragmatic default. Your auth server keeps the private key secret. Every microservice fetches the public key from a JWKS endpoint (JSON Web Key Set) and caches it. No secret distribution problem, no risk of one compromised service leaking the signing key.
// Fetch and cache the JWKS (public keys) from your auth server
import { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json')
)
// Any microservice verifies tokens using only the public keys:
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
algorithms: ['RS256'],
})JWT vs Session Cookies vs API Keys
Understanding when to use JWTs starts with understanding the alternatives. Each approach makes a different tradeoff between statefulness, revocability, and complexity.
| Property | JWT | Session Cookie | API Key |
|---|---|---|---|
| State location | Client (self-contained) | Server (DB/Redis) | Server (DB lookup) |
| Database lookup per request | No | Yes | Yes |
| Instant revocation | No (need blocklist) | Yes | Yes |
| Horizontal scale | Trivial | Requires sticky sessions or shared store | Requires shared DB |
| Cross-domain | Yes | Restricted (SameSite) | Yes |
| Best for | Microservices, SPAs, M2M | Traditional web apps | Server-to-server integrations |
The revocation problem is the most important tradeoff. A session cookie can be invalidated immediately — delete the session from Redis and the user is logged out. A JWT, once issued, is valid until it expires. To revoke a JWT early, you need a token blocklist (Redis set of jti values), which adds a database lookup on every request — erasing the statefulness benefit. For most consumer web apps with logout requirements, short-lived JWTs (15 min) paired with a refresh token strategy is the pragmatic answer.
The Access Token + Refresh Token Pattern
Industry best practice is a two-token strategy, endorsed by the IETF OAuth Working Group in RFC 6749 and refined in the OAuth 2.0 Security Best Current Practice document:
- Access token: Short-lived JWT (5–15 minutes). Sent in every API request via
Authorization: Bearer <token>header. Stored in memory (JS variable), never in localStorage. - Refresh token: Long-lived opaque token (7–30 days). Stored in an
HttpOnly; Secure; SameSite=Strictcookie. Used only to obtain new access tokens from the auth endpoint.
// Client-side token refresh (React example with automatic retry):
let accessToken: string | null = null
async function apiFetch(url: string, options: RequestInit = {}) {
if (!accessToken) {
accessToken = await refreshAccessToken()
}
const response = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${accessToken}`,
},
})
if (response.status === 401) {
// Access token expired — refresh and retry once
accessToken = await refreshAccessToken()
return fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${accessToken}` },
})
}
return response
}
async function refreshAccessToken(): Promise<string> {
const res = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include', // sends HttpOnly refresh token cookie
})
if (!res.ok) throw new Error('Session expired — please log in again')
const { accessToken } = await res.json()
return accessToken
}Implement refresh token rotation: each time a refresh token is used, issue a new one and immediately invalidate the old one. If an attacker steals the refresh token and uses it after the legitimate client has already rotated it, the server sees the reuse attempt and can invalidate all tokens for that user. This pattern is described in the OAuth Security Best Current Practice draft.
Critical JWT Security Vulnerabilities
A 2025 PortSwigger analysis found that 28% of JWT-related breaches stemmed from algorithm confusion and misimplementation — not from cryptographic weaknesses. These are the three attacks every developer must understand:
1. The alg:none Attack
If your library accepts the "none" algorithm, an attacker can forge a token by setting {"alg":"none"} and stripping the signature. The library skips verification because "no signature needed." Fix: explicitly allowlist algorithms in your verification call. Never accept "none".
2. Algorithm Confusion (RS256 → HS256)
When a server expects RS256 but a library accepts any algorithm, an attacker can switch the header to HS256 and sign the token with the server's public key (which is public). The server then verifies the HS256 signature using its public key — and succeeds. Fix: always pin the expected algorithm server-side.
3. Weak Secrets
HS256 secrets shorter than 256 bits are brute-forceable offline. If an attacker captures a JWT, they can attempt offline dictionary attacks. The JWT spec recommends at minimum a 256-bit (32-byte) random secret. Generate it properly:
# Generate a secure 256-bit secret (Node.js):
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Or with OpenSSL:
openssl rand -hex 32
# Store it in an environment variable — NEVER hardcode in source code:
JWT_SECRET=a3f8c1d9e2b74f10a5e7c3d8b2f19e4a...Where to Store JWT Tokens on the Client
| Storage Location | XSS Risk | CSRF Risk | Verdict |
|---|---|---|---|
| localStorage | High — any JS can read it | Low | Never use for access tokens |
| sessionStorage | High — same as localStorage | Low | Avoid |
| Memory (variable) | Low | Low | Best for access tokens |
| HttpOnly Cookie | None — JS cannot read it | Medium (use SameSite=Strict) | Best for refresh tokens |
The recommendation from the OWASP Authentication Cheat Sheet: store access tokens in memory, refresh tokens in HttpOnly cookies. The memory approach means tokens are lost on page refresh — solved by triggering a silent refresh on app load using the refresh token cookie.
When Not to Use JWTs
JWTs are not always the right tool. Redis argues they are outright dangerous for user sessions. Here are the cases where session cookies win:
- Instant logout requirements: If users must be immediately logged out on password change or account suspension, you need server-side sessions or a blocklist — both negate JWT's stateless advantage.
- Traditional server-rendered apps: A Rails or Django app with sticky sessions has no reason to switch to JWTs. The complexity buys nothing.
- Storing large payloads: Every JWT is sent on every request. A 2KB payload on 1,000 req/s is 2MB/s of extra bandwidth per instance.
- Low-trust environments: If you can't guarantee HTTPS everywhere, don't use JWTs in Authorization headers. Use server-side sessions with Secure cookies.
JWT in the Wild: Real-World Architecture
Here is how a typical microservices JWT flow works end-to-end:
1. User logs in → Auth Service validates credentials
Auth Service issues:
- Access JWT (RS256, 15 min exp) → sent to client in JSON body
- Refresh token (opaque) → sent as HttpOnly cookie
2. Client stores access token in memory
Every API call: Authorization: Bearer <access_token>
3. API Gateway / each microservice:
- Fetches public key from https://auth.example.com/.well-known/jwks.json
- Verifies signature, issuer, audience, expiry
- No database call needed — verification is pure CPU work
4. When access token expires (after 15 min):
- Client calls POST /api/auth/refresh (with cookie)
- Auth Service validates refresh token → issues new access token
- Optionally rotates refresh token (new one in new cookie)
5. On logout:
- Client clears in-memory access token
- Server adds refresh token to blocklist (Redis TTL = token TTL)
- Access token expires naturally within 15 minFrequently Asked Questions
What is a JWT token used for?
JWTs are used for stateless authentication and authorization. After login, the server issues a JWT the client sends with every request. The server verifies the signature — no database lookup — to confirm identity and permissions. They're also used for secure data exchange between microservices and as OIDC identity tokens.
Is a JWT the same as OAuth?
No. OAuth 2.0 is an authorization protocol — a set of flows for delegating access. JWTs are a token format. OAuth access tokens are often implemented as JWTs, but the two are independent. You can use OAuth with opaque tokens and no JWTs. You can use JWTs without OAuth. They solve different problems and frequently appear together.
Can I put sensitive data in a JWT payload?
No. The payload is Base64Url-encoded, not encrypted. Anyone who intercepts the token can decode and read it. Never store passwords, SSNs, credit card numbers, or secrets in a JWT. Keep payloads minimal: user ID, role, expiration. If you must transmit sensitive data, use JWE (JSON Web Encryption).
How do I invalidate a JWT before it expires?
Options in order of complexity: (1) Use very short expiry — a 5-min token is "revoked" quickly anyway. (2) Maintain a Redis blocklist of jti values for invalidated tokens. (3) Change the signing secret — immediately invalidates all tokens but logs everyone out. Option 2 is the standard enterprise approach.
What is the alg:none vulnerability?
Some JWT libraries accept tokens where the algorithm is set to "none" and no signature is provided. An attacker modifies any JWT payload, sets the header to {"alg":"none"}, and strips the signature. The library accepts it as valid. Fix: always explicitly allowlist accepted algorithms in your verification call.
Should I use HS256 or RS256?
HS256 if one server both signs and verifies — simpler, faster. RS256 if multiple services need to verify tokens independently: only the auth server holds the private key; every other service uses the public key from your JWKS endpoint. For new microservices architectures in 2026, RS256 or ES256 is the standard recommendation.
What is a JWKS endpoint?
A JWKS (JSON Web Key Set) endpoint is a public URL — typically .well-known/jwks.json — that serves the public keys used to verify JWTs. Services cache the keys and use them for verification without ever knowing the private key. It's the standard key distribution mechanism for RS256 and ES256 in microservices.
JWT Quick-Reference Checklist
- ✅ Always set
exp— never issue non-expiring JWTs - ✅ Set
issandaud, validate both on verification - ✅ Use a minimum 256-bit random secret for HS256
- ✅ Pin algorithm in verification code — never accept 'none'
- ✅ Store access tokens in memory, refresh tokens in HttpOnly cookies
- ✅ Keep payload small — avoid user objects, use IDs + roles only
- ✅ Never log full JWT strings — logs are often less protected than your auth service
- ✅ Rotate refresh tokens on each use
- ❌ Never store JWTs in localStorage or sessionStorage
- ❌ Never put secrets, passwords, or sensitive PII in the payload
- ❌ Never use the same JWT for both authentication and CSRF protection
Inspect a JWT Right Now
Paste any JWT into our decoder to split the header, payload, and signature — runs entirely in your browser, nothing is sent to a server.
Open JWT Decoder →