It's 2am, your users can't log in, and the error logs say "invalid token." You pull the JWT from the request headers, but it's a 300-character string that looks like gibberish. Where do you even start?
This guide breaks down exactly how JWTs work, how to debug them when they break, and the security mistakes that keep showing up in production.
What a JWT Actually Is
A JSON Web Token (JWT, pronounced "jot") is a signed JSON object encoded as a URL-safe string. It's defined in RFC 7519 and has become the default token format for web authentication.
The core idea: instead of storing session data on the server, encode it directly in the token. The server signs the token with a secret key so it can verify the token hasn't been tampered with — without looking anything up in a database.
Here's a real JWT:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c3JfYTFiMmMzIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzA2MTQwODAwLCJleHAiOjE3MDYxNDQ0MDB9.7K8G3j_R2F0xBab30RMHrHDcEfxjoYZgeFONFh7HgQ
Looks random, but it's three Base64URL-encoded segments separated by dots. Paste that into the JWT decoder and you'll see exactly what's inside.
The Three Parts, Decoded
Part 1: Header
{
"alg": "HS256",
"typ": "JWT"
}
The header declares two things: what type of token this is (JWT) and what algorithm was used to sign it (HS256). That's it.
Common algorithms:
| Algorithm | Type | Key | Best For |
|---|---|---|---|
| HS256 | Symmetric (HMAC + SHA-256) | Single shared secret | Monolithic apps where one server signs and verifies |
| HS384/HS512 | Symmetric (HMAC + SHA-384/512) | Single shared secret | Same as HS256 with stronger hash |
| RS256 | Asymmetric (RSA + SHA-256) | Private key signs, public key verifies | Microservices, third-party integrations |
| ES256 | Asymmetric (ECDSA + P-256) | Private key signs, public key verifies | Same as RS256 but smaller keys and signatures |
| EdDSA | Asymmetric (Ed25519) | Private key signs, public key verifies | Modern systems wanting best performance + security |
For HS256, the signing process uses HMAC — a cryptographic function that combines a secret key with the token content to produce a signature. If you change even one character in the header or payload, the HMAC output changes completely.
Part 2: Payload (Claims)
{
"sub": "usr_a1b2c3",
"role": "admin",
"email": "ada@example.com",
"iat": 1706140800,
"exp": 1706144400
}
The payload contains claims — statements about the user. There are three types:
Registered claims (defined by the JWT spec):
| Claim | Name | Example | Purpose |
|---|---|---|---|
sub | Subject | "usr_a1b2c3" | Who this token is about (user ID) |
iss | Issuer | "auth.example.com" | Who created this token |
aud | Audience | "api.example.com" | Who this token is intended for |
exp | Expiration | 1706144400 | When this token expires (Unix timestamp) |
iat | Issued At | 1706140800 | When this token was created |
nbf | Not Before | 1706140800 | Token isn't valid before this time |
jti | JWT ID | "a1b2c3d4" | Unique token identifier (for revocation/replay prevention) |
Public claims — custom claims with globally unique names (e.g., "https://example.com/roles").
Private claims — custom claims agreed upon between parties (e.g., "role", "team_id", "permissions").
The exp and iat values are Unix timestamps. If you need to check when a token expires, convert the timestamp — the JWT decoder does this automatically and flags expired tokens.
Part 3: Signature
The signature is what makes JWTs trustworthy. For HS256:
HMAC-SHA256(
base64url(header) + "." + base64url(payload),
secret_key
)
The server:
- Takes the encoded header and payload (the first two parts of the token)
- Runs them through HMAC-SHA256 with a secret key
- Compares the result to the signature in the token
If they match, the token is authentic and unmodified. If any bit of the header or payload was changed — even adding a space — the signature won't match.
For asymmetric algorithms (RS256, ES256), the process is similar but uses a private/public key pair. The auth server signs with the private key, and any service with the public key can verify.
The Authentication Flow (Step by Step)
Here's what actually happens in a typical JWT-based auth system:
1. Client sends credentials
POST /login { "email": "ada@example.com", "password": "..." }
2. Server verifies credentials against database
✓ Password matches bcrypt hash
3. Server creates JWT
Header: { "alg": "HS256", "typ": "JWT" }
Payload: { "sub": "usr_a1b2c3", "role": "admin", "exp": 1706144400 }
Sign with secret key → eyJhbGci...
4. Server returns JWT to client
{ "access_token": "eyJhbGci...", "token_type": "bearer" }
5. Client stores JWT
(HTTP-only cookie or in-memory variable)
6. Client sends JWT with every request
GET /api/users
Authorization: Bearer eyJhbGci...
7. Server verifies signature
✓ Signature valid, token not expired
→ Reads claims, processes request
8. No database lookup needed
The user's ID and role are right there in the token
The performance advantage is real. Instead of hitting a session store on every request, the server just verifies a cryptographic signature — a sub-millisecond operation.
Decoding vs. Verifying: The Critical Difference
This catches junior developers off guard: JWT payloads are not encrypted. They're Base64URL-encoded, which means anyone can decode them.
// Anyone can do this — no key needed
const payload = JSON.parse(atob("eyJ1c2VyX2lkIjoxMjM0NX0"));
// { "user_id": 12345 }
Decoding = reading the payload. Anyone with the token can do this.
Verifying = checking the signature against a key. Only the server (with the secret) can do this.
The payload is Base64-encoded, not encrypted. Never put sensitive data (passwords, credit card numbers, SSNs) in a JWT payload. Anyone who intercepts the token can read it.
The Access Token + Refresh Token Pattern
Short-lived access tokens solve the revocation problem:
Access Token: expires in 15 minutes, used for API requests
Refresh Token: expires in 7 days, used only to get new access tokens
Here's the flow:
1. User logs in → gets access token (15min) + refresh token (7d)
2. Client uses access token for API calls
3. Access token expires after 15 minutes
4. Client sends refresh token to /auth/refresh
5. Server issues new access token (and optionally new refresh token)
6. If refresh token is expired or revoked → user must log in again
Why this works for revocation: When you need to revoke a user's access (fired employee, compromised account), you invalidate their refresh token. They'll lose access within 15 minutes when their current access token expires and they can't get a new one.
Store refresh tokens in your database so you can revoke them. Store access tokens nowhere — they're stateless and self-validating.
Security Vulnerabilities (With Fixes)
1. The alg: "none" Attack
Some JWT libraries accept tokens with "alg": "none" — meaning no signature at all. An attacker modifies the payload, sets the algorithm to "none," removes the signature, and the server accepts it.
Fix: Always validate the algorithm on the server. Never accept "none". Most modern libraries reject it by default, but verify your configuration:
// ✅ Explicitly specify allowed algorithms
jwt.verify(token, secret, { algorithms: ["HS256"] });
// ❌ Don't let the token tell you which algorithm to use
jwt.verify(token, secret); // dangerous — trusts the token's alg header
2. Algorithm Confusion
If your server is configured for RS256 (asymmetric), an attacker might change the header to HS256 (symmetric) and sign the token using the public key as the HMAC secret. If the server's verification code doesn't enforce the algorithm, it might accept the forged token.
Fix: Always specify the expected algorithm in your verification code. Never trust the alg value from the token header.
3. Weak Secrets
For HMAC-based algorithms, a weak secret can be brute-forced:
# Tools like hashcat can crack weak JWT secrets
hashcat -m 16500 jwt.txt wordlist.txt
Fix: Use a cryptographically random secret of at least 256 bits (32 bytes). Generate one:
openssl rand -base64 32
4. Token Leakage via URL Parameters
Never pass JWTs in URL query strings:
# ❌ Token appears in server logs, browser history, Referer headers
GET /api/data?token=eyJhbGci...
Fix: Use the Authorization header or HTTP-only cookies.
5. Storing Tokens in localStorage
// ❌ Vulnerable to XSS — any script can read this
localStorage.setItem("token", jwt);
// ✅ Use HTTP-only cookies — inaccessible to JavaScript
// Set via server response header:
// Set-Cookie: token=eyJ...; HttpOnly; Secure; SameSite=Strict
JWTs vs. Session Cookies: When to Use Each
This is one of the most debated topics in web development. Here's a practical comparison:
| Dimension | JWTs | Session Cookies |
|---|---|---|
| State | Stateless — data is in the token | Stateful — data is on the server |
| Database load | No session lookup needed | Session lookup on every request |
| Scalability | Easy — no shared state between servers | Need shared session store (Redis, database) |
| Revocation | Hard — token is valid until it expires | Easy — delete the session |
| Token size | Larger (carries all claims, ~800-2000 bytes) | Small (just a session ID, ~32 bytes) |
| Cross-domain | Easy — send in Authorization header | Hard — cookies are domain-bound |
| Mobile/API clients | Natural fit — header-based auth | Awkward — cookies are a browser concept |
Use JWTs when:
- Building APIs consumed by mobile apps or third-party clients
- Running microservices that need to verify identity without a shared database
- Implementing cross-domain authentication (SSO)
- You need stateless, horizontally scalable auth
Use session cookies when:
- Building a traditional server-rendered web app
- You need instant revocation (e.g., "log out everywhere" button)
- Token size matters (session IDs are tiny)
- You're running a single server and don't need distributed auth
Many production systems use both: JWTs for API authentication between services, session cookies for the user-facing web app.
Debugging JWT Issues
When authentication breaks, here's the checklist:
- Decode the token — paste it into the JWT decoder to see the header and payload
- Check
exp— is the token expired? This is the most common issue - Check
iatand server time — clock skew between servers causes verification failures - Check the algorithm — does the token's
algmatch what the server expects? - Check the audience/issuer — if you validate
audoriss, do they match? - Check the secret/key — did someone rotate the signing key without updating all services?
- Check for whitespace — extra spaces or newlines in the token string cause parse failures
JWT Libraries by Language
| Language | Library | Notes |
|---|---|---|
| Node.js | jose | Modern, standards-compliant, supports all algorithms |
| Node.js | jsonwebtoken | Older but widely used, HS256/RS256 focus |
| Python | PyJWT | Clean API, supports all standard algorithms |
| Go | golang-jwt/jwt | The successor to dgrijalva/jwt-go |
| Java | nimbus-jose-jwt | Comprehensive, enterprise-grade |
| Rust | jsonwebtoken | Fast, well-maintained |
| C#/.NET | System.IdentityModel.Tokens.Jwt | Built into .NET |
Key Takeaways
- JWTs are signed, not encrypted. Anyone can read the payload. Don't put secrets in it.
- Always verify the signature. Never decode without verifying. Never trust the
algheader. - Keep access tokens short-lived — 15 minutes is a good default. Use refresh tokens for longer sessions.
- Use HTTP-only cookies for browser storage. Never localStorage.
- Match the algorithm to your architecture — HS256 for monoliths, RS256/ES256 for microservices.
- When debugging, start by decoding the token to check expiration and claims. The JWT decoder makes this instant.
Sources: RFC 7519 — JSON Web Token (IETF), RFC 7518 — JSON Web Algorithms (IETF), OWASP JWT Security Cheat Sheet, Auth0 introduction to JWTs. Code examples tested in Node.js 22.
