Difficulty: Intermediate
JSON Web Tokens (JWT) are the most popular method for implementing stateless authentication in modern web APIs. A JWT is a compact, URL-safe string that encodes a JSON payload and is cryptographically signed by the server. When a user logs in, the server creates a JWT containing the user's identity information and sends it back. The client stores this token and includes it in subsequent requests, allowing the server to verify the user's identity without querying a database on every request.
A JWT consists of three parts separated by dots: the header, the payload, and the signature. The header specifies the algorithm used for signing (typically HS256 or RS256). The payload contains claims - key-value pairs with user data like user ID, email, and role, plus standard claims like `iat` (issued at) and `exp` (expiration time). The signature is created by signing the encoded header and payload with a secret key. Anyone can decode the header and payload (they are just Base64-encoded), but only the server with the secret key can create a valid signature.
In Node.js, the `jsonwebtoken` package provides `jwt.sign()` to create tokens and `jwt.verify()` to validate them. When signing, you pass the payload, a secret key, and options like expiration time. When verifying, the library checks the signature integrity and expiration. If the token has been tampered with or has expired, jwt.verify() throws an error.
Token expiration is critical for security. Access tokens should be short-lived (15 minutes to 1 hour) to minimize damage if stolen. Refresh tokens are long-lived tokens (days to weeks) used solely to obtain new access tokens without requiring the user to log in again. The refresh token is stored securely (often in an httpOnly cookie) and is exchanged for a fresh access token when the current one expires.
The standard way to send JWTs is in the Authorization header using the Bearer scheme: `Authorization: Bearer <token>`. The server extracts the token from this header in middleware, verifies it, and attaches the decoded user information to the request object for downstream route handlers to use.
const jwt = require('jsonwebtoken');
const SECRET = 'my-secret-key-change-in-production';
// Create a token
const payload = { userId: 42, email: 'alice@example.com', role: 'student' };
const token = jwt.sign(payload, SECRET, { expiresIn: '1h' });
console.log('Token:', token.substring(0, 50) + '...');
// Decode without verifying (anyone can do this)
const decoded = jwt.decode(token);
console.log('Decoded payload:', decoded);
// Verify the token (checks signature + expiry)
try {
const verified = jwt.verify(token, SECRET);
console.log('Verified:', verified.userId, verified.email);
} catch (err) {
console.log('Invalid token:', err.message);
}
// Verify with wrong secret
try {
jwt.verify(token, 'wrong-secret');
} catch (err) {
console.log('Wrong secret:', err.message);
}
jwt.sign() creates a signed token with the payload and expiration. jwt.decode() reads the payload without verification (useful for debugging only). jwt.verify() both decodes and validates the signature and expiration, throwing if anything is wrong.
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET || 'dev-secret';
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, SECRET);
req.user = { id: decoded.userId, email: decoded.email, role: decoded.role };
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}
// Usage in routes
// app.get('/profile', authMiddleware, (req, res) => {
// res.json({ user: req.user });
// });
console.log('Auth middleware defined');
The middleware extracts the Bearer token from the Authorization header, verifies it, and attaches the decoded user data to req.user. If verification fails, it returns 401. This pattern is the standard for protecting API routes.
const jwt = require('jsonwebtoken');
const ACCESS_SECRET = 'access-secret';
const REFRESH_SECRET = 'refresh-secret';
function generateTokens(userId) {
const accessToken = jwt.sign(
{ userId },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId },
REFRESH_SECRET,
{ expiresIn: '7d' }
);
return { accessToken, refreshToken };
}
function refreshAccessToken(refreshToken) {
try {
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
const newAccessToken = jwt.sign(
{ userId: decoded.userId },
ACCESS_SECRET,
{ expiresIn: '15m' }
);
console.log('New access token issued for user:', decoded.userId);
return newAccessToken;
} catch (error) {
console.log('Refresh failed:', error.message);
return null;
}
}
const tokens = generateTokens(42);
console.log('Access token expires in 15m');
console.log('Refresh token expires in 7d');
const newAccess = refreshAccessToken(tokens.refreshToken);
console.log('New token created:', !!newAccess);
This demonstrates the dual-token strategy. Short-lived access tokens protect API routes. When an access token expires, the client sends the refresh token to get a new access token without re-entering credentials. The refresh token uses a different secret for additional security.
jwt.sign, jwt.verify, payload, token expiry, refresh tokens, Bearer token