What Is a JWT
A JSON Web Token (JWT) is a self-contained, stateless credential that encodes claims about an identity. It's three Base64url-encoded parts separated by dots: header.payload.signature. Unlike session tokens stored in a server database, JWTs carry their data in the token itself, making them lightweight for microservices and distributed systems.
A real JWT looks like:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkFsaWNlIiwiaWF0IjoxNjcwMzM4MDAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
The JWT is signed (not encrypted) by the server, allowing any client or service to verify its authenticity without a central database lookup. This makes JWTs ideal for API authentication, mobile clients, and cross-origin requests.
The Header Explained
The first part of a JWT is the header, a Base64url-encoded JSON object containing metadata about the token:
{
"alg": "HS256",
"typ": "JWT"
}
The alg field specifies the cryptographic algorithm used to sign the token. This is critical: it tells the server how to verify the signature. Common algorithms include:
HS256- HMAC with SHA-256 (symmetric: same key signs and verifies)RS256- RSA with SHA-256 (asymmetric: private key signs, public key verifies)ES256- ECDSA with SHA-256 (asymmetric, more efficient than RSA)none- No signature (deprecated and dangerous)
The typ field is almost always JWT and can be safely ignored. It's metadata for HTTP Content-Type headers and doesn't affect validation.
alg field is not trusted. An attacker can modify it. Your validation code must always explicitly set the expected algorithm rather than trusting what the header says.
The Payload (Claims) in Detail
The payload is a JSON object containing claims-statements about the token's subject. There are three categories:
Registered Claims (Standard)
These are reserved claim names defined by the JWT specification:
sub- Subject (who the token is about, usually user ID)iss- Issuer (who created the token, e.g., your auth service)aud- Audience (which service(s) should accept this token)exp- Expiration time (Unix timestamp when token expires)iat- Issued at (Unix timestamp when token was created)nbf- Not before (Unix timestamp; token invalid until this time)jti- JWT ID (unique token identifier, useful for revocation)
Private Claims (Custom Data)
You can add custom claims for application-specific needs:
{
"sub": "user:[email protected]",
"iss": "https://auth.myapp.com",
"aud": "https://api.myapp.com",
"exp": 1703203200,
"iat": 1703199600,
"role": "admin",
"permissions": ["read:posts", "write:posts", "delete:users"],
"org_id": "org:acme"
}
Custom claims let you embed authorization data directly in the token, avoiding extra database lookups on every request.
The Signature: How It Works
The signature is computed over the encoded header and payload, binding them together and proving they haven't been tampered with.
HMAC-SHA256 (HS256): Symmetric Signing
With HMAC, both the server that creates the token and the server that validates it share a secret key:
signature = HMAC-SHA256( secret_key, base64url(header) + "." + base64url(payload) )
To verify: recompute the HMAC with the same key and check it matches. If the header, payload, or key differs, the HMAC will be completely different.
RSA-SHA256 (RS256): Asymmetric Signing
With RSA, the server signs with a private key and clients verify with the corresponding public key:
- Server holds private key (secret)
- Public key is distributed to all services that need to verify tokens
- Only the private key can create a valid signature; the public key can only verify it
HS256 vs RS256: Which to Use
HS256 (symmetric): Simpler, faster. Use when you have a single issuer and all token consumers are within your organization with secure communication channels.
RS256 (asymmetric): Better for microservices architectures. Multiple independent services can verify tokens without sharing a secret key. Safer if any token-consuming service is compromised-it can only verify, not forge tokens. Suitable for distributed systems and third-party integrations.
The `alg: none` Attack
One of JWT's most dangerous vulnerabilities stems from a misunderstanding: the specification allows alg: none, meaning no signature at all. An attacker can modify any JWT to:
// Attacker's modified header
{
"alg": "none",
"typ": "JWT"
}
// Attacker modifies the payload to elevate privileges
{
"sub": "user:alice",
"role": "user"
// Changed to:
// "role": "admin"
}
// No signature needed, so they send:
// eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyOmFsaWNlIiwicm9sZSI6ImFkbWluIn0.
If a naive implementation accepts this without validating the alg field, the attacker escalates to admin without knowing any secret keys.
alg: none. Do not trust the header's alg claim; enforce the algorithm your application expects at validation time.
Token Expiry and the Revocation Problem
The exp claim sets an expiration time as a Unix timestamp. Once expired, the token is invalid. This solves one problem but creates another: how do you revoke a token that hasn't expired yet
Unlike session tokens (stored in a database), JWTs are stateless. The server can't "delete" a token because it doesn't store it. If a user logs out at 2:00 PM but their token expires at 3:00 PM, they could still use the token for that hour.
Short-Lived Tokens + Refresh Token Pattern
The industry standard is to issue two tokens:
- Access token: Short-lived (15 minutes to 1 hour), used for API requests, small payload
- Refresh token: Long-lived (days to months), used only to get a new access token, typically stored in httpOnly cookie
When the access token expires, the client sends the refresh token to get a new access token. If a user logs out, invalidate their refresh token in the database. The compromised access token becomes useless when it expires.
// Logout invalidates the refresh token
POST /auth/logout
{
"refresh_token": "..."
}
// Server deletes or blacklists the refresh token
// Access tokens issued from that refresh token are now unrecoverable
This balances statelessness with revocation: access tokens stay stateless and fast, but refresh tokens provide an invalidation point.
Where to Store JWTs: localStorage vs HttpOnly Cookies
Both approaches have tradeoffs:
localStorage
- Pro: Survives page reloads; not automatically sent with requests (you control it in JavaScript)
- Con: Vulnerable to XSS (Cross-Site Scripting). Any malicious JavaScript on the page can read and steal the token
HttpOnly Cookies
- Pro: Inaccessible to JavaScript; automatically sent with same-origin requests; immune to XSS (attacker can't read it, though they can still make requests with it)
- Con: Vulnerable to CSRF (Cross-Site Request Forgery) if you don't validate the origin; requires SameSite and Secure flags to be safe
Recommendation: Store refresh tokens in httpOnly cookies and access tokens in memory (or localStorage if you accept the XSS risk). Use SameSite=Strict and Secure flags.
// Set refresh token in httpOnly cookie Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Strict; Path=/
For SPAs accessing your own backend, httpOnly cookies are the strongest option. For cross-origin scenarios or mobile apps, localStorage is necessary despite the XSS risk-mitigate with strict Content Security Policy (CSP).
What NOT to Put in a JWT Payload
JWTs are signed but not encrypted by default. Anyone with the token can Base64url-decode and read every claim. Never include:
- Passwords or password hashes
- API keys or secrets
- Credit card numbers or sensitive PII
- Medical or financial data
- Any data that would be a breach if logged in plain text
If you need encrypted JWTs, use JWE (JSON Web Encryption), which wraps the JWT in additional encryption. But this adds complexity and is rarely necessary if you're using HTTPS.
Real-World Example: Inspecting a Token
Let's decode a realistic JWT step-by-step. Use our JWT Decoder tool to try this interactively.
Raw token:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyOjEyMzQ1Iiwibm..." (truncated)
Header (decoded):
{
"alg": "RS256",
"typ": "JWT"
}
Payload (decoded):
{
"sub": "user:12345",
"iss": "https://auth.example.com",
"aud": "https://api.example.com",
"exp": 1703203200,
"iat": 1703199600,
"name": "Alice Johnson",
"email": "[email protected]",
"role": "user",
"permissions": ["read:posts", "create:posts"],
"org_id": "org:acme"
}
The signature is a cryptographic hash of the header and payload. It proves the token was issued by someone holding the private key, and that no part of it has been modified.
Best Practices Summary
- Always verify the algorithm: Don't trust the header. Explicitly enforce your expected algorithm in validation code.
- Use short-lived access tokens: Combine with refresh tokens for a balance between statelessness and revocation.
- Sign with RS256 in distributed systems: Asymmetric keys are safer and simpler for multi-service architectures.
- Store refresh tokens in httpOnly cookies: Access tokens can go in memory or localStorage.
- Never encrypt JWTs unless you specifically need JWE: HTTPS protects transmission; the signature proves authenticity.
- Validate registered claims: Check
exp,iss, andaudevery time. - Use jti for critical operations: Track issued token IDs to revoke specific tokens if compromised.
Tools & Further Reading
Explore JWTs interactively with these tools:
- JWT Decoder - Inspect and verify tokens
- Base64 Encoder/Decoder - Understand how JWTs are encoded
- Hash Generator - See how HMAC-SHA256 works
Related learning guides:
- JSON Guide - Understanding the payload structure
- Base64 Encoding Explained - How JWT parts are encoded
- Hashing Guide - Cryptographic foundations of signatures