JWT Tokens Explained: Structure, Security, and Best Practices
· 12 min read
JSON Web Tokens (JWTs) have become the de facto standard for stateless authentication in modern web applications. Whether you're building a REST API, a microservices architecture, or a single-page application, understanding how JWTs work is essential for implementing secure, scalable authentication.
This comprehensive guide breaks down everything you need to know about JWTsβfrom their internal structure to production-ready security practices. By the end, you'll understand not just how to use JWTs, but when to use them and how to avoid common pitfalls that lead to security vulnerabilities.
Table of Contents
- What is a JWT?
- JWT Structure: Header, Payload, and Signature
- Standard Claims (RFC 7519)
- How JWT Authentication Works
- Implementation Examples
- Security Best Practices
- Refresh Tokens and Token Rotation
- JWT vs Session-Based Authentication
- Common Mistakes to Avoid
- Where to Store JWTs
- Frequently Asked Questions
- Related Articles
What is a JWT?
A JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is digitally signed using JSON Web Signature (JWS).
Think of a JWT as a tamper-proof container for user information. When a user logs in, your server creates a JWT containing their user ID, roles, and other relevant data. This token is then sent to the client, which includes it with every subsequent request. The server can verify the token's authenticity without querying a database, making JWTs ideal for stateless, scalable architectures.
JWTs are defined by RFC 7519 and are widely supported across programming languages and frameworks. They're particularly popular in:
- Single Sign-On (SSO) systems where users authenticate once and access multiple services
- Microservices architectures where services need to verify user identity without shared session storage
- Mobile applications where maintaining server-side sessions is impractical
- API authentication where stateless verification improves scalability
Quick tip: Use our JWT Decoder to inspect and debug any JWT token instantly. It's completely client-side, so your tokens never leave your browser.
JWT Structure: Header, Payload, and Signature
A JWT consists of three Base64URL-encoded parts separated by dots (.). Here's what a real JWT looks like:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Breaking this down:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 β Header
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ β Payload
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c β Signature
| Part | Contains | Example (decoded) |
|---|---|---|
| Header | Algorithm + token type | {"alg": "HS256", "typ": "JWT"} |
| Payload | Claims (user data) | {"sub": "1234567890", "name": "John Doe", "iat": 1516239022} |
| Signature | Verification hash | HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret) |
The Header
The header typically consists of two parts: the token type (typ) and the signing algorithm (alg). Common algorithms include:
- HS256 (HMAC with SHA-256): Symmetric algorithm using a shared secret
- RS256 (RSA with SHA-256): Asymmetric algorithm using public/private key pairs
- ES256 (ECDSA with SHA-256): Asymmetric algorithm with smaller key sizes
The Payload
The payload contains the claimsβstatements about an entity (typically the user) and additional metadata. Claims are not encrypted, only encoded, so never put sensitive information like passwords in a JWT payload.
The Signature
The signature ensures the token hasn't been tampered with. It's created by taking the encoded header, encoded payload, a secret key, and applying the algorithm specified in the header. If anyone modifies the header or payload, the signature verification will fail.
Pro tip: Generate test JWTs with custom claims using our JWT Generator tool. Perfect for development and testing scenarios.
Standard Claims (RFC 7519)
RFC 7519 defines several standard claims that provide interoperability between different JWT implementations. While these claims are optional, using them correctly improves security and compatibility.
| Claim | Name | Purpose | Example |
|---|---|---|---|
iss |
Issuer | Identifies who created the token | "https://auth.example.com" |
sub |
Subject | Identifies who the token is about (usually user ID) | "user_12345" |
aud |
Audience | Identifies who the token is intended for | "https://api.example.com" |
exp |
Expiration | When the token expires (Unix timestamp) | 1735689600 |
nbf |
Not Before | Token is not valid before this time | 1735603200 |
iat |
Issued At | When the token was created | 1735603200 |
jti |
JWT ID | Unique identifier for the token (useful for revocation) | "a1b2c3d4-e5f6" |
Custom Claims
Beyond standard claims, you can add custom claims specific to your application. Common examples include:
roleorroles: User permissions (e.g.,"admin","user")email: User's email addressname: User's display namescope: OAuth 2.0 scopes for API accesstenant_id: Multi-tenant application identifier
Keep your payload smallβevery byte adds to network overhead. Include only the claims you need for authorization decisions. If you need additional user data, fetch it from your database using the sub claim as the lookup key.
How JWT Authentication Works
Understanding the complete authentication flow helps you implement JWTs correctly and securely. Here's the typical sequence:
- User Login: User sends credentials (username + password) to your authentication endpoint
- Credential Verification: Server validates credentials against your user database
- Token Generation: Server creates a JWT containing user claims and signs it with a secret key
- Token Delivery: Server returns the JWT to the client (typically in the response body or as an httpOnly cookie)
- Token Storage: Client stores the JWT securely (more on this in the Storage Options section)
- Authenticated Requests: Client includes the JWT in the
Authorizationheader for subsequent requests - Token Verification: Server verifies the signature and checks expirationβno database lookup needed
- Access Granted: If valid, server processes the request using claims from the token
Here's a visual representation:
ββββββββββ ββββββββββ
β Client β β Server β
βββββ¬βββββ βββββ¬βββββ
β β
β POST /login {username, password} β
βββββββββββββββββββββββββββββββββββββββββββ>β
β β
β [Verify credentials]
β [Generate JWT]
β β
β 200 OK {token: "eyJhbG..."} β
β<βββββββββββββββββββββββββββββββββββββββββββ
β β
[Store token] β
β β
β GET /api/profile β
β Authorization: Bearer eyJhbG... β
βββββββββββββββββββββββββββββββββββββββββββ>β
β β
β [Verify signature]
β [Check expiration]
β [Extract claims]
β β
β 200 OK {user data} β
β<βββββββββββββββββββββββββββββββββββββββββββ
β β
Pro tip: The beauty of JWTs is that step 7 (token verification) doesn't require a database query. The server can verify authenticity and extract user information directly from the token, making your API highly scalable.
Implementation Examples
Let's look at practical implementations across popular programming languages and frameworks.
Node.js with jsonwebtoken
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
// Login endpoint
app.post('/login', async (req, res) => {
const { username, password } = req.body;
// Find user in database
const user = await User.findOne({ username });
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Verify password
const validPassword = await bcrypt.compare(password, user.passwordHash);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Create JWT
const token = jwt.sign(
{
sub: user.id,
email: user.email,
role: user.role
},
process.env.JWT_SECRET,
{
expiresIn: '15m',
issuer: 'https://api.example.com',
audience: 'https://example.com'
}
);
res.json({ token });
});
// Protected route middleware
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
};
// Use the middleware
app.get('/api/profile', authenticateToken, (req, res) => {
res.json({ userId: req.user.sub, email: req.user.email });
});
Python with PyJWT
import jwt
import datetime
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET_KEY = 'your-secret-key'
@app.route('/login', methods=['POST'])
def login():
data = request.get_json()
username = data.get('username')
password = data.get('password')
# Verify credentials (simplified)
user = verify_credentials(username, password)
if not user:
return jsonify({'error': 'Invalid credentials'}), 401
# Create JWT
payload = {
'sub': user['id'],
'email': user['email'],
'role': user['role'],
'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=15),
'iat': datetime.datetime.utcnow()
}
token = jwt.encode(payload, SECRET_KEY, algorithm='HS256')
return jsonify({'token': token})
def token_required(f):
def decorator(*args, **kwargs):
token = request.headers.get('Authorization')
if not token:
return jsonify({'error': 'No token provided'}), 401
try:
# Remove 'Bearer ' prefix
token = token.split(' ')[1]
decoded = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
request.user = decoded
except jwt.ExpiredSignatureError:
return jsonify({'error': 'Token expired'}), 403
except jwt.InvalidTokenError:
return jsonify({'error': 'Invalid token'}), 403
return f(*args, **kwargs)
return decorator
@app.route('/api/profile')
@token_required
def profile():
return jsonify({'userId': request.user['sub']})
Go with golang-jwt
package main
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID string `json:"sub"`
Email string `json:"email"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func generateToken(userID, email, role string) (string, error) {
claims := Claims{
UserID: userID,
Email: email,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "https://api.example.com",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte("your-secret-key"))
}
func verifyToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte("your-secret-key"), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, jwt.ErrSignatureInvalid
}
Security Best Practices
JWTs are only as secure as your implementation. Follow these practices to avoid common vulnerabilities:
Token Expiration
Always set short expiration times for access tokens. A good rule of thumb:
- Access tokens: 15 minutes to 1 hour
- Refresh tokens: 7 days to 30 days
Short-lived tokens limit the damage if a token is compromised. Use refresh tokens (covered below) to issue new access tokens without requiring the user to log in again.
Algorithm Selection
Never use the none algorithm in production. Always explicitly specify and validate the algorithm:
// Bad - accepts any algorithm
jwt.verify(token, secret);
// Good - explicitly requires HS256
jwt.verify(token, secret, { algorithms: ['HS256'] });
For microservices or when you need to distribute public keys, prefer RS256 over HS256. With RS256, services only need the public key to verify tokens, while only the authentication service holds the private key for signing.
Secret Key Management
Your JWT secret is the foundation of security. Treat it like a password:
- Use a cryptographically random string (at least 256 bits for HS256)
- Store secrets in environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault)
- Never commit secrets to version control
- Rotate secrets periodically
- Use different secrets for different environments (dev, staging, production)
Validate All Claims
Don't just verify the signatureβvalidate the claims too:
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'https://api.example.com',
audience: 'https://example.com',
clockTolerance: 30 // Allow 30 seconds clock skew
});
// Additional validation
if (decoded.exp < Date.now() / 1000) {
throw new Error('Token expired');
}
Security Checklist
| Do | Don't |
|---|---|
| Use short expiration times (15 min) | Set tokens to never expire |
| Store in httpOnly cookies for web apps | Store in localStorage (XSS vulnerable) |
| Use HTTPS in production | Send tokens over unencrypted HTTP |
| Validate issuer, audience, and expiration | Only check the signature |
| Use strong, random secrets (256+ bits) | Use weak or predictable secrets |
| Implement refresh token rotation | Reuse the same refresh token indefinitely |
| Log token verification failures | Ignore suspicious activity |
| Use RS256 for distributed systems | Share HS256 secrets across services |
Pro tip: Enable rate limiting on your authentication endpoints to prevent brute force attacks. Even with JWTs, your login endpoint is still vulnerable to credential stuffing.
Refresh Tokens and Token Rotation
Access tokens should be short-lived, but forcing users to log in every 15 minutes creates a terrible user experience. The solution? Refresh tokens.
How Refresh Tokens Work
When a user logs in, issue two tokens:
- Access token: Short-lived (15 min), used for API requests
- Refresh token: Long-lived (7-30 days), used only to obtain new access tokens
When the access token expires, the client sends the refresh token to a dedicated endpoint to get a new access token. This happens transparently without user interaction.
Implementation Example
// Login endpoint returns both tokens
app.post('/login', async (req, res) => {
// ... verify credentials ...
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ sub: user.id, type: 'refresh' },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Store refresh token in database for revocation capability
await RefreshToken.create({
token: refreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
res.json({ accessToken, refreshToken });
});
// Refresh endpoint
app.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: 'No refresh token' });
}
try {
// Verify refresh token
const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
// Check if token exists in database (not revoked)
const storedToken = await RefreshToken.findOne({
token: refreshToken,
userId: decoded.sub
});
if (!storedToken) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
// Issue new access token
const newAccessToken = jwt.sign(
{ sub: decoded.sub, role: decoded.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
} catch (err) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
});
Refresh Token Rotation
For maximum security, implement refresh token rotation. Each time a refresh token is used, issue a new refresh token and invalidate the old one:
// Enhanced refresh endpoint with rotation
app.post('/refresh', async (req, res) => {
const { refreshToken } = req.body;
// ... verify token ...
// Issue new access token AND new refresh token
const newAccessToken = jwt.sign(
{ sub: decoded.sub, role: decoded.role },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const newRefreshToken = jwt.sign(
{ sub: decoded.sub, type: 'refresh' },
process.env.REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Delete old refresh token
await RefreshToken.deleteOne({ token: refreshToken });
// Store new refresh token
await RefreshToken.create({
token: newRefreshToken,
userId: decoded.sub,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
});
res.json({
accessToken: newAccessToken,
refreshToken: newRefreshToken
});
});
This approach detects token theft. If an attacker uses a stolen refresh token, the legitimate user's next refresh attempt will fail, alerting you to the compromise.
JWT vs Session-Based Authentication
Should you use JWTs or traditional server-side sessions? The answer depends on your architecture and requirements.
When to Use JWTs
- Microservices: Services can verify tokens independently without shared session storage
- Mobile apps: No need to maintain server-side session state
- Single Sign-On (SSO): One token works across multiple domains
- Stateless APIs: Horizontal scaling without sticky sessions
- Cross-domain authentication: Tokens work across different origins
When to Use Sessions
- Monolithic applications: Simpler to implement and manage
- Immediate revocation required: Sessions can be invalidated instantly
- Sensitive applications: Banking, healthcare where you need fine-grained control
- Small user base: Session storage overhead is negligible
Comparison Table
| Feature | JWT | Sessions |
|---|---|---|
| Storage | Client-side (token) | Server-side (session store) |
| Scalability | Excellent (stateless) | Requires shared session store or sticky sessions |
| Revocation | Difficult (requires blacklist) | Immediate (delete session) |
| Size | Larger (sent with every request) | Smaller (just session ID) |
| Security | Vulnerable to XSS if stored in localStorage | Vulnerable to CSRF if not protected |
| Cross-domain | Easy (include in Authorization header) | Difficult (cookie domain restrictions) |
| Mobile apps | Ideal | Awkward (no cookie support) |
Quick tip: You can combine both approaches. Use sessions for your main web application and JWTs for your mobile API. They're not mutually exclusive.
Common Mistakes to Avoid
Even experienced developers make these JWT mistakes. Learn from others' errors:
1. Storing Sensitive Data in the Payload
JWTs are encoded, not encrypted. Anyone can decode a JWT and read its contents. Never include:
- Passwords or password hashes
- Credit card numbers
- Social security numbers
- API keys or secrets
If you need to include sensitive data, use JWE (JSON Web Encryption) instead of JWS (JSON Web Signature).
2. Not Validating the Algorithm
The infamous "none" algorithm vulnerability