Imagine a digital key that fits perfectly into a lock, granting access only if it’s genuine and untampered. This key isn’t physical but a compact, encoded token that secretly carries verified information between two parties. This is the power of a JSON Web Token, or JWT—an elegant solution transforming how web applications authenticate and authorize users securely and efficiently.
If you've ever worked on a web or mobile application that requires user authentication, you've likely encountered JSON Web Tokens (JWT). But what exactly are they, and why have they become the de facto standard for authentication in modern applications?
Simply put, a JWT is like a security ticket that the server issues when you successfully log in. Every subsequent request you make to the server includes this ticket, allowing you to access protected resources without logging in repeatedly. This elegant solution has revolutionized how we handle authentication in distributed systems and stateless applications.
A JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed.
A JWT is not just a random string of characters. It consists of three distinct parts, each separated by a dot (.):
xxxxx.yyyyy.zzzzz
The header typically consists of two parts:
Example Header:
{
"alg": "HS256",
"typ": "JWT"
}
This header is then Base64Url encoded to form the first part of the JWT.
The payload contains the claims. Claims are statements about an entity (typically the user) and additional metadata. There are three types of claims:
iss (issuer), exp (expiration time), sub (subject), aud (audience)Example Payload:
{
"sub": "1234567890",
"name": "John Doe",
"email": "[email protected]",
"iat": 1516239022,
"exp": 1516242622,
"role": "admin"
}
The payload is also Base64Url encoded to form the second part of the JWT.
The signature is used to verify that the message wasn't changed along the way. To create the signature, you take:
Example Signature Creation (pseudo-code):
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
Here's what a complete JWT looks like:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
If you decode this token, you'll see:
Header:
{
"alg": "HS256",
"typ": "JWT"
}
Payload:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
Let's walk through a complete authentication flow using JWT:
When a user logs in with their credentials (username and password), the server validates these credentials.
Example Login Request:
POST /api/auth/login
Content-Type: application/json
{
"username": "johndoe",
"password": "securepassword123"
}
Server Response:
HTTP/1.1 200 OK
Content-Type: application/json
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
"expiresIn": 3600
}
The client (web browser, mobile app) stores this token, typically in:
Every subsequent request includes the JWT in the Authorization header:
Example Authenticated Request:
GET /api/user/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
The server:
exp claim)Example Server Validation (Node.js/Express):
const jwt = require('jsonwebtoken');
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.sendStatus(401); // Unauthorized
}
jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
if (err) {
return res.sendStatus(403); // Forbidden (invalid token)
}
req.user = user; // Attach user info to request
next(); // Continue to the next middleware
});
}
// Usage in route
app.get('/api/user/profile', authenticateToken, (req, res) => {
res.json({ user: req.user });
});
The signature in a JWT is crucial for security. It ensures:
If someone tries to modify the payload (e.g., change the user ID to impersonate another user), the signature will no longer match, and the server will reject the token.
Example Attack Scenario:
Original Token Payload:
{
"sub": "user123",
"role": "user"
}
Attacker tries to modify:
{
"sub": "admin456",
"role": "admin"
}
When the server verifies the signature, it will detect the tampering and reject the request.
The signature proves that the token was issued by a trusted authority (your server) and hasn't been tampered with.
The signature ensures that the token cannot be denied as being issued by the server.
Common JWT signing algorithms include:
Example: Creating a JWT with HS256 (Node.js)
const jwt = require('jsonwebtoken');
const payload = {
userId: '1234567890',
email: '[email protected]',
role: 'admin'
};
const secret = 'your-secret-key';
const token = jwt.sign(payload, secret, { expiresIn: '1h' });
console.log(token);
Example: Creating a JWT with RS256 (Asymmetric)
const jwt = require('jsonwebtoken');
const fs = require('fs');
const privateKey = fs.readFileSync('private-key.pem');
const payload = {
userId: '1234567890',
email: '[email protected]'
};
const token = jwt.sign(payload, privateKey, {
algorithm: 'RS256',
expiresIn: '1h'
});
JWTs are particularly useful in the following scenarios:
Perfect for microservices architectures where you don't want to maintain session state on the server.
Example: Microservices Architecture
User → API Gateway → Service A (validates JWT)
→ Service B (validates JWT)
→ Service C (validates JWT)
Each service can independently verify the JWT without querying a central session store.
When your frontend and backend are on different domains, JWTs work seamlessly.
Example:
https://app.example.comhttps://api.example.comThe JWT can be sent via CORS without cookie restrictions.
Mobile apps can store JWTs securely and include them in API requests.
Example: React Native
import AsyncStorage from '@react-native-async-storage/async-storage';
// Store token after login
await AsyncStorage.setItem('authToken', token);
// Retrieve and use in API calls
const token = await AsyncStorage.getItem('authToken');
fetch('https://api.example.com/user/profile', {
headers: {
'Authorization': `Bearer ${token}`
}
});
JWTs enable users to log in once and access multiple related applications.
Example SSO Flow:
User logs in → Auth Service issues JWT
→ User accesses App A (validates JWT)
→ User accesses App B (validates JWT)
→ User accesses App C (validates JWT)
RESTful APIs and GraphQL APIs commonly use JWTs for authentication.
Example: GraphQL with JWT
const { ApolloServer } = require('apollo-server');
const jwt = require('jsonwebtoken');
const server = new ApolloServer({
context: ({ req }) => {
const token = req.headers.authorization || '';
if (token) {
try {
const user = jwt.verify(token.replace('Bearer ', ''), SECRET);
return { user };
} catch (e) {
return { user: null };
}
}
return { user: null };
},
// ... typeDefs and resolvers
});
JWTs are stateless, meaning the server doesn't need to store session information. This makes horizontal scaling much easier.
Example: Load Balancing
User Request → Load Balancer → Server 1 (validates JWT)
→ Server 2 (validates JWT)
→ Server 3 (validates JWT)
Any server can validate the JWT without checking a shared session store.
No database lookups required for authentication on each request (after initial validation).
Performance Comparison:
Traditional Session:
Request → Check Session Store (DB Query) → Validate → Response
Time: ~50-100ms
JWT:
Request → Validate Signature (In-Memory) → Response
Time: ~1-5ms
JWTs work seamlessly across different domains and platforms.
All necessary information is in the token itself, reducing server-side lookups.
Easy to implement in mobile applications without cookie management.
JWTs can be larger than session IDs, increasing request size.
Size Comparison:
Session ID: "sess_abc123xyz" (16 bytes)
JWT: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." (200-500 bytes)
Once issued, JWTs are valid until expiration. Revoking a token before expiration requires additional mechanisms.
Solutions for Token Revocation:
Option A: Token Blacklist
// Store revoked tokens in Redis
const redis = require('redis');
const client = redis.createClient();
async function revokeToken(token) {
const decoded = jwt.decode(token);
const ttl = decoded.exp - Math.floor(Date.now() / 1000);
await client.setex(`blacklist:${token}`, ttl, '1');
}
async function isTokenRevoked(token) {
const result = await client.get(`blacklist:${token}`);
return result === '1';
}
Option B: Short-Lived Tokens with Refresh Tokens
// Access token: Short-lived (15 minutes)
const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
// Refresh token: Long-lived (7 days), stored in database
const refreshToken = jwt.sign({ userId: payload.userId }, secret, {
expiresIn: '7d'
});
// Store refresh token in database for revocation
await db.refreshTokens.create({ userId, token: refreshToken });
If a JWT is stolen, an attacker can use it until expiration. This is why:
Security Best Practices:
// Good: HTTP-only cookie
res.cookie('token', token, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // Only sent over HTTPS
sameSite: 'strict' // CSRF protection
});
// Better: Short expiration + refresh token
const accessToken = jwt.sign(payload, secret, { expiresIn: '15m' });
const refreshToken = jwt.sign({ userId }, secret, { expiresIn: '7d' });
JWTs are signed, not encrypted by default. Sensitive data should not be stored in the payload unless the JWT is encrypted (JWE - JSON Web Encryption).
Example: Encrypted JWT (JWE)
const jose = require('jose');
// Encrypt the JWT
const secretKey = await jose.generateSecret('HS256');
const jwt = await new jose.EncryptJWT({ 'userId': '123' })
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt()
.setExpirationTime('2h')
.encrypt(secretKey);
Large payloads increase token size and may hit URL length limits when passed as query parameters.
Let's build a complete authentication system using JWT:
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const app = express();
app.use(express.json());
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const REFRESH_SECRET = process.env.REFRESH_SECRET || 'your-refresh-secret';
// Mock user database
const users = [
{
id: '1',
username: 'johndoe',
password: '$2b$10$hashedpassword', // bcrypt hash
email: '[email protected]',
role: 'user'
}
];
// Login endpoint
app.post('/api/auth/login', async (req, res) => {
const { username, password } = req.body;
// Find user
const user = users.find(u => u.username === username);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Verify password
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Generate tokens
const accessToken = jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role
},
JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
res.json({
accessToken,
refreshToken,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role
}
});
});
// Middleware to verify JWT
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
req.user = user;
next();
});
}
// Protected route
app.get('/api/user/profile', authenticateToken, (req, res) => {
res.json({
message: 'Profile data',
user: req.user
});
});
// Refresh token endpoint
app.post('/api/auth/refresh', (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ error: 'Refresh token required' });
}
jwt.verify(refreshToken, REFRESH_SECRET, (err, decoded) => {
if (err) {
return res.status(403).json({ error: 'Invalid refresh token' });
}
const user = users.find(u => u.id === decoded.userId);
if (!user) {
return res.status(403).json({ error: 'User not found' });
}
const newAccessToken = jwt.sign(
{
userId: user.id,
email: user.email,
role: user.role
},
JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
});
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
import React, { createContext, useContext, useState, useEffect } from 'react';
import axios from 'axios';
const AuthContext = createContext();
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check for stored token on mount
const token = localStorage.getItem('accessToken');
if (token) {
// Verify token is still valid
fetchUserProfile(token);
} else {
setLoading(false);
}
}, []);
const fetchUserProfile = async (token) => {
try {
const response = await axios.get('http://localhost:3000/api/user/profile', {
headers: { Authorization: `Bearer ${token}` }
});
setUser(response.data.user);
} catch (error) {
// Token invalid, clear storage
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
} finally {
setLoading(false);
}
};
const login = async (username, password) => {
try {
const response = await axios.post('http://localhost:3000/api/auth/login', {
username,
password
});
localStorage.setItem('accessToken', response.data.accessToken);
localStorage.setItem('refreshToken', response.data.refreshToken);
setUser(response.data.user);
return { success: true };
} catch (error) {
return {
success: false,
error: error.response?.data?.error || 'Login failed'
};
}
};
const logout = () => {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
setUser(null);
};
const refreshAccessToken = async () => {
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
logout();
return null;
}
try {
const response = await axios.post('http://localhost:3000/api/auth/refresh', {
refreshToken
});
localStorage.setItem('accessToken', response.data.accessToken);
return response.data.accessToken;
} catch (error) {
logout();
return null;
}
};
// Axios interceptor for automatic token refresh
axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 403 && !originalRequest._retry) {
originalRequest._retry = true;
const newToken = await refreshAccessToken();
if (newToken) {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return axios(originalRequest);
}
}
return Promise.reject(error);
}
);
// Set default authorization header
axios.defaults.headers.common['Authorization'] =
localStorage.getItem('accessToken')
? `Bearer ${localStorage.getItem('accessToken')}`
: '';
return (
<AuthContext.Provider value={{ user, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
// Generate a strong secret
const crypto = require('crypto');
const secret = crypto.randomBytes(64).toString('hex');
console.log(secret); // Use this as JWT_SECRET
// Rotate refresh tokens on each use
app.post('/api/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
// Verify and decode
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
// Revoke old refresh token
await revokeToken(refreshToken);
// Issue new tokens
const newAccessToken = generateAccessToken(decoded.userId);
const newRefreshToken = generateRefreshToken(decoded.userId);
res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
});
const rateLimit = require('express-rate-limit');
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5 // 5 attempts per window
});
app.post('/api/auth/login', loginLimiter, async (req, res) => {
// Login logic
});
function requireRole(role) {
return (req, res, next) => {
if (req.user.role !== role) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
app.delete('/api/admin/users/:id',
authenticateToken,
requireRole('admin'),
deleteUser
);
JSON Web Tokens have become an essential part of modern web development, offering a stateless, scalable solution for authentication and authorization. While they have their limitations, when implemented correctly with proper security measures, JWTs provide an efficient and flexible way to handle authentication across different platforms and services.
Understanding how JWTs work, their structure, and best practices for implementation will help you build more secure and scalable applications. Remember to:
Whether you're building a simple web application or a complex microservices architecture, JWTs can provide the authentication foundation you need.
Happy coding! 🚀