What a JWT Looks Like
A JSON Web Token is three Base64URL-encoded strings joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MDAyMDAwMDAsImV4cCI6MTcwMDIwMzYwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Paste that into the DevLab JWT Decoder and you see three JSON objects. The third part — the signature — cannot be decoded into readable JSON; it is a cryptographic value. But the first two are just Base64URL encoding of plain JSON.
The Header
{
"alg": "HS256",
"typ": "JWT"
}
alg specifies the signing algorithm. typ is always "JWT". These two fields tell the verifying party how to check the signature.
The Payload (Claims)
{
"sub": "user_123",
"email": "alice@example.com",
"iat": 1700200000,
"exp": 1700203600,
"iss": "https://auth.example.com",
"aud": "https://api.example.com"
}
Standard registered claims (defined in RFC 7519):
sub— Subject: who the token is about (usually a user ID)iat— Issued At: Unix timestamp when the token was createdexp— Expiration: Unix timestamp after which the token is invalidiss— Issuer: who created the tokenaud— Audience: who the token is intended fornbf— Not Before: token is invalid before this timejti— JWT ID: unique identifier to prevent replay attacks
You can add any custom claim: "role": "admin", "org_id": 42. The payload is NOT encrypted — anyone with the token can read these values. Never put passwords, credit card numbers, or PII you cannot expose.
The Signature
The signature proves the token was issued by someone who knows the secret, and that the header and payload have not been modified since signing:
signature = HMACSHA256(
base64url(header) + "." + base64url(payload),
secret
)
If anyone changes a single character in the header or payload, the signature will not match. The verifier rejects the token.
HS256 vs RS256: Symmetric vs Asymmetric Signing
This is the most important architectural decision when implementing JWT:
HS256 (HMAC-SHA256 — symmetric):
- Uses one shared secret. Both the issuer and every verifier need the same secret.
- Fast and simple for single-service architectures.
- Problem: if you have 10 microservices all verifying JWTs, all 10 must hold the secret. A breach of any service exposes the signing key — an attacker can forge arbitrary tokens.
RS256 (RSA-SHA256 — asymmetric):
- Uses a public/private key pair. The auth server signs with its private key. All other services verify with the public key.
- The public key can be published openly (e.g., at
/.well-known/jwks.json) — compromising a verifier does not expose the signing secret. - The right choice for microservices, third-party integrations, and any system where verification happens outside the signing authority.
// Verify with RS256 in Node.js (using jose library)
import { jwtVerify, createRemoteJWKSet } from 'jose'
const JWKS = createRemoteJWKSet(new URL('https://auth.example.com/.well-known/jwks.json'))
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
})
JWT vs Sessions: When to Use Which
Use sessions when:
- You need instant revocation — a stolen session can be deleted server-side immediately
- You have a single backend server or a shared session store (Redis)
- You are building a traditional server-rendered web app
- Token size matters — a session ID is 32 bytes vs a JWT at 200-500+ bytes sent on every request
Use JWTs when:
- Multiple independent services need to verify identity without calling a central auth server
- You are building a stateless API consumed by mobile apps or SPAs
- You need cross-domain authentication
- Short-lived tokens (15-minute expiry) with refresh token rotation are acceptable
Security Mistakes That Get Apps Breached
- Algorithm confusion ("alg: none" attack): Early JWT libraries trusted the
algheader field from the token itself. Settingalg: "none"bypassed signature verification entirely. Always specify the expected algorithm explicitly when verifying — never trust the header. - Storing JWTs in localStorage: XSS attacks can steal localStorage values. Use httpOnly cookies so JavaScript cannot access the token at all.
- Long-lived tokens without rotation: A JWT stolen today is valid for however long the expiry is set. Use 15-minute access tokens with refresh token rotation to limit the window of exposure.
- Not verifying the
expclaim: Always check expiration server-side. Some libraries verify signature but skip expiry validation if you do not configure it. - Not validating
issandaud: A valid JWT from your login service should not be accepted by your payment service. Always verify issuer and audience. - Sensitive data in payload: The payload is Base64-encoded, not encrypted. Any party holding the token can read it without the secret key.
Verifying a JWT in Practice
// Node.js with jsonwebtoken
import jwt from 'jsonwebtoken'
// Sign
const token = jwt.sign(
{ sub: 'user_123', email: 'alice@example.com' },
process.env.JWT_SECRET,
{ expiresIn: '15m', issuer: 'https://auth.example.com' }
)
// Verify (ALWAYS specify algorithms explicitly)
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'https://auth.example.com',
})
// payload.sub === 'user_123'
} catch (err) {
// TokenExpiredError, JsonWebTokenError, NotBeforeError
console.error('Invalid token:', err.message)
}