Authentication is the cornerstone of modern web application security. Among the various authentication methods available today, JSON Web Tokens (JWTs) have emerged as one of the most widely adopted solutions for API authentication. Their popularity stems from providing a secure, stateless, and highly scalable approach to verifying clients.
In this comprehensive guide, we’ll dive deep into JWT authentication—explaining not just how it works, but also why it has become the go-to solution for modern applications.
What is a JSON Web Token?
A JWT is an open standard (RFC 7519) that defines a compact and self-contained way to securely transmit information between parties as a JSON object. This information is digitally signed, making it verifiable and trustworthy.
A JWT consists of three parts, separated by dots:
- Header: Identifies which algorithm was used to generate the signature
- Payload: Contains the claims or the actual data
- Signature: Ensures the token hasn’t been altered
The resulting structure looks like this:
xxxxx.yyyyy.zzzzz
The JWT Authentication Flow: Step by Step
Let’s break down the entire JWT authentication process with practical examples:
1. Client Authentication
The process begins when a client (user, application, or device) needs to authenticate with your backend. Typically, this involves submitting credentials through a login form:
<form id="loginForm">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" required>
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" required>
</div>
<button type="submit">Log In</button>
</form>
The client-side JavaScript might look like: javascript
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (response.ok) {
// Store JWT (covered in step 5)
localStorage.setItem('token', data.token);
window.location.href = '/dashboard';
} else {
alert('Login failed: ' + data.message);
}
} catch (error) {
console.error('Login error:', error);
}
});
2. Server Verification
Once the server receives these credentials, it verifies them against the user database: javascript
// Server-side authentication handler (Node.js/Express example)
app.post('/api/auth/login', async (req, res) => {
const { username, password } = req.body;
try {
// Find user in database
const user = await User.findOne({ username });
if (!user) {
return res.status(401).json({ message: 'Invalid credentials' });
}
// Verify password (using bcrypt)
const isMatch = await bcrypt.compare(password, user.passwordHash);
if (!isMatch) {
return res.status(401).json({ message: 'Invalid credentials' });
}
// Proceed to JWT issuance
// ...
} catch (error) {
res.status(500).json({ message: 'Server error' });
}
});
3. JWT Issuance
After successful authentication, the server generates a JWT containing relevant user information:
// Continuing from the previous code block...
const jwt = require('jsonwebtoken');
// Generate JWT
const payload = {
sub: user.id, // Subject (user ID)
name: user.name, // User's name
roles: user.roles, // User's roles/permissions
iat: Math.floor(Date.now() / 1000), // Issued at
exp: Math.floor(Date.now() / 1000) + (60 * 60), // Expires in 1 hour
iss: 'your-api.com', // Issuer
aud: 'your-app-frontend' // Audience
};
// Sign with secret key (HS256)
const token = jwt.sign(payload, process.env.JWT_SECRET);
// Alternatively, for asymmetric signing (RS256):
// const token = jwt.sign(payload, privateKey, { algorithm: 'RS256' });
// Send token to client
res.json({ token });
4. Token Delivery
The server responds with the JWT in the response body, as shown in the code above.
5. Secure Storage
Once the client receives the token, it needs to store it securely. There are several storage options with different security implications:
Option 1: HTTP-only cookies (most secure)
// Server-side setting of HTTP-only cookie
res.cookie('token', token, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // Only sent over HTTPS
sameSite: 'strict', // CSRF protection
maxAge: 3600000 // 1 hour in milliseconds
}).json({ success: true });
Option 2: Local Storage (convenient but less secure)
// Client-side storage
localStorage.setItem('token', token);
Option 3: In-memory storage (session-only)
// Client-side in-memory storage
let token; // Stored in a variable, lost when page refreshes
// Store token received from API
function storeToken(receivedToken) {
token = receivedToken;
}
6. API Requests with JWT
For subsequent requests to protected API endpoints, the client includes the JWT in the Authorization header:
// Example of making authenticated requests
async function fetchProtectedResource() {
// Option 1: With HTTP-only cookies, no manual token inclusion needed
// Option 2 & 3: Manual token inclusion
const token = localStorage.getItem('token'); // or use in-memory token
try {
const response = await fetch('/api/protected-resource', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
// Process protected resource data
return data;
} else {
// Handle unauthorized or other errors
if (response.status === 401) {
// Token expired or invalid, redirect to login
window.location.href = '/login';
}
}
} catch (error) {
console.error('Request failed:', error);
}
}
7. Server Validates the JWT
When the server receives the request with the JWT, it verifies its validity:
// JWT verification middleware (Node.js/Express)
const jwt = require('jsonwebtoken');
function authenticateToken(req, res, next) {
// Get token from Authorization header
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ message: 'Access token required' });
}
try {
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
audience: 'your-app-frontend',
issuer: 'your-api.com'
});
// Add user info to request object
req.user = decoded;
// Continue to the protected route
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ message: 'Token expired' });
}
return res.status(403).json({ message: 'Invalid token' });
}
}
// Use middleware for protected routes
app.get('/api/protected-resource', authenticateToken, (req, res) => {
// Access user info from token
const userId = req.user.sub;
// Process the protected resource request
// ...
});
8. Token Expiration & Refresh
Since JWTs should have a relatively short lifetime for security reasons, you’ll need a refresh mechanism:
// Refresh token endpoint
app.post('/api/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token required' });
}
try {
// Verify refresh token (stored in database)
const refreshTokenDoc = await RefreshToken.findOne({ token: refreshToken });
if (!refreshTokenDoc || refreshTokenDoc.isRevoked) {
return res.status(403).json({ message: 'Invalid refresh token' });
}
// Verify token hasn't expired
if (refreshTokenDoc.expiresAt < new Date()) {
return res.status(403).json({ message: 'Refresh token expired' });
}
// Get user from refresh token
const user = await User.findById(refreshTokenDoc.userId);
// Generate new access token
const payload = {
sub: user.id,
name: user.name,
roles: user.roles,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + (60 * 60),
iss: 'your-api.com',
aud: 'your-app-frontend'
};
const token = jwt.sign(payload, process.env.JWT_SECRET);
// Return new access token
res.json({ token });
} catch (error) {
res.status(500).json({ message: 'Server error' });
}
});
The client-side refresh flow:
// Automatic token refresh when receiving 401 responses
async function refreshToken() {
const refreshToken = localStorage.getItem('refreshToken');
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('token', data.token);
return data.token;
} else {
// Refresh failed, redirect to login
window.location.href = '/login';
return null;
}
} catch (error) {
console.error('Refresh error:', error);
window.location.href = '/login';
return null;
}
}
Benefits of JWT Authentication
- Stateless: Servers don’t need to maintain session state, improving scalability.
- Cross-domain: JWTs work seamlessly across different domains.
- Performance: Reduced database lookups for authentication checks.
- Decentralized: Multiple services can accept the same token.
- Rich data: Can contain user roles and permissions directly in the token.
Security Best Practices
- Use short expiration times for access tokens (15-60 minutes).
- Implement refresh tokens for obtaining new access tokens.
- Store tokens securely using HTTP-only cookies with the Secure and SameSite flags.
- Use strong signing keys and consider asymmetric algorithms (RS256) for larger systems.
- Validate all claims, especially
exp
(expiration),iss
(issuer), andaud
(audience). - Maintain a token blocklist for revoked tokens if immediate invalidation is required.
- Never store sensitive data in the JWT payload, as it’s easily decoded.
Conclusion
JWT authentication provides a robust, scalable solution for securing modern applications. By understanding the complete flow and implementing the security best practices outlined in this guide, you can build authentication systems that are both secure and performant.
Remember that while JWTs offer numerous advantages, they also come with specific challenges like token revocation and secure storage. Always consider your application’s security requirements when implementing any authentication mechanism.