JWT Tokens Explained — How Authentication Works in Modern Web Apps
Learn how JSON Web Tokens work, what's inside them, how to decode and verify them, and common security pitfalls. Practical guide for developers.
Introduction
JSON Web Tokens (JWTs) are everywhere. If you've built a web app in the last decade, you've probably used them for authentication. They're in your Authorization headers, your cookies, and your API requests. But what's actually inside a JWT? How do they work? And what can go wrong?
This article breaks down the anatomy of a JWT, explains how signing and verification work, and covers the security pitfalls that catch developers off guard.
What Is a JWT?
A JWT is a compact, URL-safe string that represents a set of claims. It's used to transmit information between two parties in a way that can be verified and trusted. The most common use case is authentication: after a user logs in, the server issues a JWT that the client includes with subsequent requests to prove their identity.
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Three parts separated by dots. That's it. No binary encoding, no special protocol — just a string you can pass in an HTTP header.
Anatomy of a JWT
Part 1: Header
The first segment is the header, Base64URL-encoded:
{
"alg": "HS256",
"typ": "JWT"
}
It specifies the signing algorithm (HS256 = HMAC-SHA256) and the token type. Other common algorithms include RS256 (RSA-SHA256), ES256 (ECDSA), and none (unsigned — dangerous, we'll get to that).
Part 2: Payload
The second segment contains the claims — the actual data:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022,
"exp": 1516242622,
"role": "admin"
}
Standard claims include:
sub(subject): who the token is aboutiat(issued at): when the token was created (Unix timestamp)exp(expiration): when the token expiresiss(issuer): who created the tokenaud(audience): who the token is intended for
You can add any custom claims you want (role, email, permissions, etc.). Just remember that the payload is not encrypted — anyone can decode it. Use the JWT decoder tool to see this in action: paste any JWT and instantly see the decoded header and payload.
Part 3: Signature
The third segment is the signature, created by:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
The signature ensures the token hasn't been tampered with. If someone changes a single character in the header or payload, the signature won't match and the server will reject the token.
How JWT Authentication Works
- User logs in with username and password
- Server verifies credentials against the database
- Server creates a JWT with the user's ID, role, and expiration time, signs it with a secret key
- Server sends the JWT to the client (in the response body or a cookie)
- Client stores the JWT (localStorage, sessionStorage, or cookie)
- Client includes the JWT in the Authorization header of subsequent requests:
Authorization: Bearer <token> - Server verifies the signature on each request and extracts the user info from the payload
The key advantage: the server doesn't need to store session state. The JWT itself contains all the information needed to authenticate the request. This makes JWTs popular in microservices architectures where multiple services need to verify authentication independently.
JWTs vs Session Cookies
| Feature | JWT | Session Cookie |
|---|---|---|
| State storage | Client-side | Server-side |
| Scalability | Stateless, easy to scale | Requires shared session store |
| Revocation | Hard (token is valid until expiry) | Easy (delete the session) |
| Size | Larger (carries all claims) | Small (just a session ID) |
| Cross-domain | Works easily with CORS | Tricky with cross-domain |
JWTs aren't universally better. For traditional server-rendered apps, session cookies are simpler and easier to revoke. JWTs shine in API-first architectures, single-page apps, and microservices.
Security Pitfalls
1. The alg: none Attack
Some JWT libraries accept tokens with "alg": "none", which means no signature verification. An attacker can forge a token, set the algorithm to none, and the server accepts it. Always validate the algorithm on the server side and reject none.
2. Storing Tokens in localStorage
localStorage is accessible to any JavaScript running on the page. If your site has an XSS vulnerability, an attacker can steal the JWT. Prefer HttpOnly cookies for token storage — they're not accessible via JavaScript.
3. Not Checking Expiration
Always check the exp claim. A JWT without an expiration is valid forever, even after the user changes their password or their account is suspended. Set short expiration times (15 minutes to 1 hour) and use refresh tokens for longer sessions.
4. Sensitive Data in the Payload
The payload is Base64-encoded, not encrypted. Anyone who intercepts the token can decode it instantly. Never put passwords, credit card numbers, or other sensitive data in the payload. Paste a JWT into the decoder to see how easily the contents are readable.
5. Weak Secrets
For HMAC-signed tokens, the secret key must be strong. A short or guessable secret can be brute-forced. Use at least 256 bits of randomness. For RS256, use a proper RSA key pair.
Decoding JWTs for Debugging
During development, you'll constantly need to inspect JWT contents. Common scenarios:
- Checking claims: Is the user ID correct? Is the role right?
- Debugging expiration: Has the token expired? When was it issued?
- Verifying structure: Are all required claims present?
You can decode JWTs in several ways:
- Use the JWT decoder tool for a visual breakdown with expiration checking
- In the terminal:
echo '<payload>' | base64 -d | python -m json.tool - In code: most JWT libraries have a decode-without-verify function for debugging
Token Refresh Flow
Since JWTs should have short lifetimes, you need a way to get new tokens without making the user log in again. The standard approach:
- Issue two tokens at login: a short-lived access token (15 min) and a long-lived refresh token (7 days)
- Client uses the access token for API requests
- When the access token expires, client sends the refresh token to a dedicated endpoint
- Server verifies the refresh token and issues a new access token
- If the refresh token is expired or revoked, the user must log in again
Store the refresh token in an HttpOnly cookie and the access token in memory (not localStorage). This limits the blast radius of XSS attacks.
Conclusion
JWTs are a powerful tool for stateless authentication, but they come with tradeoffs. Understand what's inside them, sign them properly, set short expirations, and don't store them where JavaScript can reach them.
When debugging, the JWT decoder gives you instant visibility into any token's contents and expiration status — entirely in your browser, so sensitive tokens never leave your machine.