What Is a JWT?
A JSON Web Token (JWT) is a compact, URL-safe token format defined in RFC 7519. It's used to represent claims between two parties โ typically an authentication server and your application.
You've seen them: that long eyJ... string in an Authorization: Bearer header. Every modern auth system (Auth0, Cognito, Supabase, Firebase) issues JWTs.
The Three-Part Structure
A JWT is three Base64URL-encoded JSON objects joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20iLCJpYXQiOjE3MzAwMDAwMDAsImV4cCI6MTczMDA4NjQwMH0
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header โ algorithm and token type:
{
"alg": "HS256",
"typ": "JWT"
}
Payload โ the claims (user data):
{
"sub": "user_123",
"email": "alice@example.com",
"iat": 1730000000,
"exp": 1730086400
}
Signature โ proof of integrity:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
The signature is what makes the token tamper-proof โ but it doesn't encrypt the payload. Anyone can read the header and claims by Base64-decoding them.
Registered Claims (the Standard Fields)
| Claim | Full Name | Description |
|---|---|---|
iss | Issuer | Who issued the token |
sub | Subject | Who the token is about (user ID) |
aud | Audience | Who the token is intended for |
exp | Expiration | Unix timestamp when token expires |
nbf | Not Before | Token not valid before this time |
iat | Issued At | When the token was issued |
jti | JWT ID | Unique identifier for this token |
Always include exp. A token with no expiry is valid forever โ a massive security risk if it leaks.
Signing Algorithms
Symmetric (shared secret)
HS256 / HS384 / HS512 โ HMAC with SHA-256/384/512
The same secret is used to sign and verify. Simple, fast, and works well when the issuer and verifier are the same service.
# Signing and verifying both use the same secret
SECRET="your-256-bit-secret"
Risk: Anyone with the secret can forge tokens. Never use HS256 across trust boundaries.
Asymmetric (public/private key)
RS256 / RS384 / RS512 โ RSA with SHA
The server signs with a private key; anyone can verify with the public key. Ideal for:
- Third-party services verifying your tokens
- Microservices where you don't want every service holding a signing secret
- Auth providers like Auth0 and AWS Cognito
ES256 / ES384 / ES512 โ ECDSA with SHA
Same model as RSA but smaller keys and faster verification. Preferred over RS256 for new systems.
The alg:none Attack
One of the most dangerous JWT vulnerabilities. Some naive libraries accept a token with "alg": "none" and no signature:
// Header
{ "alg": "none", "typ": "JWT" }
// Payload
{ "sub": "admin", "role": "superuser" }
// Signature: (empty)
An attacker can craft a token claiming to be any user and the library accepts it.
Fix: Always explicitly specify which algorithms are acceptable when verifying. Never allow none.
// Bad
jwt.verify(token, secret)
// Good โ whitelist the algorithm
jwt.verify(token, secret, { algorithms: ["HS256"] })
Algorithm Confusion Attack
Attackers sometimes switch a token from RS256 to HS256 and sign with the public key as the HMAC secret (since public keys are... public). A library that doesn't enforce the expected algorithm will verify this as valid.
Fix: Same as above โ always lock down the allowed algorithms.
Token Expiry and Refresh
Short-lived access tokens + long-lived refresh tokens is the standard pattern:
Access token: 15 minutes (stored in memory, not localStorage)
Refresh token: 7โ30 days (stored in httpOnly cookie)
When the access token expires:
- Client sends the refresh token to
/auth/refresh - Server validates refresh token, issues new access token
- Optionally rotates the refresh token (recommended)
Where to Store JWTs
| Storage | XSS Risk | CSRF Risk | Recommendation |
|---|---|---|---|
localStorage | High โ | None | Avoid for sensitive tokens |
sessionStorage | High โ | None | Slightly better, same XSS risk |
| Memory (JS var) | None โ | None | Best for access tokens |
| httpOnly cookie | None โ | Medium | Best for refresh tokens |
Use an httpOnly, Secure, SameSite=Strict cookie for refresh tokens. Store access tokens in memory and re-issue them from the refresh token on page reload.
Decoding Without a Library
Since the payload is just Base64URL-encoded JSON, you can decode it anywhere:
function decodeJwt(token) {
const [, payload] = token.split(".");
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
return JSON.parse(json);
}
This only decodes โ it does not verify the signature. Use this for debugging and logging, never for authorization decisions.
Common JWT Pitfalls
- Not verifying the signature โ decoding โ verifying
- Trusting
expfrom the token โ verify it server-side with a known-good time - Putting sensitive data in the payload โ it's readable by anyone
- No
audcheck โ a token from service A could be replayed at service B - Long expiry times โ a leaked token stays valid until expiry
- Not using HTTPS โ JWTs in transit can be intercepted
Try It: ToolNinja JWT Decoder
Paste any JWT into the ToolNinja JWT Decoder to instantly see the decoded header, payload, and expiry status. Works 100% in your browser โ the token never leaves your machine.