Table of Contents
- Understanding JWT
- Prerequisites
- Setting Up the Project
- User Registration: Storing Users Securely
- User Login & JWT Generation
- Protecting Routes with JWT Middleware
- Refresh Tokens: Enhancing Security
- Security Best Practices
- Troubleshooting Common Issues
- Conclusion
- References
1. Understanding JWT
1.1 JWT Structure
A JWT is a compact string formatted as xxxx.yyyy.zzzz, where each segment is base64url-encoded and separated by dots. It consists of three parts:
Header
The header specifies the token type (JWT) and the signing algorithm (e.g., HS256 for HMAC-SHA256 or RS256 for RSA-SHA256). Example:
{
"alg": "HS256",
"typ": "JWT"
}
Payload
The payload contains “claims”—statements about the user (e.g., user ID, role) and metadata (e.g., expiration time). Claims are not encrypted (only base64-encoded), so avoid sensitive data here. Standard claims include:
exp: Expiration time (timestamp).iat: Issued at time (timestamp).sub: Subject (e.g., user ID).
Example payload:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022,
"exp": 1516242622
}
Signature
The signature ensures the token hasn’t been tampered with. It’s created by hashing the header (base64-encoded), payload (base64-encoded), and a secret key (or private key for RSA) using the algorithm specified in the header:
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret
)
1.2 How JWT Authentication Works
- User Login: The user submits credentials (e.g., email/password) to the server.
- Token Generation: The server validates credentials, then signs and returns a JWT (and optionally a refresh token).
- Client Storage: The client stores the JWT (e.g., in
localStorage, session storage, or secure cookies). - Protected Requests: For subsequent requests to protected routes, the client sends the JWT in the
Authorizationheader:Authorization: Bearer <jwt_token> - Token Verification: The server extracts the JWT, verifies its signature, and checks claims (e.g., expiration). If valid, the request is allowed; otherwise, it’s rejected.
2. Prerequisites
To follow along, you’ll need:
- Node.js (v14+ recommended) and npm/yarn installed.
- Basic knowledge of Express.js and REST APIs.
- A MongoDB database (local or MongoDB Atlas).
- A code editor (e.g., VS Code).
3. Setting Up the Project
We’ll use Node.js, Express, and MongoDB (with Mongoose ODM) for this tutorial. Let’s initialize the project:
Step 1: Create a Project Folder
mkdir jwt-auth-demo && cd jwt-auth-demo
npm init -y
Step 2: Install Dependencies
Install required packages:
npm install express mongoose jsonwebtoken bcryptjs dotenv cors
npm install --save-dev nodemon
express: Web framework for building APIs.mongoose: ODM for MongoDB (simplifies database interactions).jsonwebtoken: Library to generate/verify JWTs.bcryptjs: For hashing passwords securely.dotenv: To manage environment variables (e.g., JWT secret).cors: To handle cross-origin requests.
Step 3: Configure Environment Variables
Create a .env file to store sensitive data:
# .env
PORT=5000
MONGODB_URI=mongodb://localhost:27017/jwt-auth-demo # or your Atlas URI
JWT_SECRET=your_strong_jwt_secret_key_here # Use a long, random string
JWT_EXPIRES_IN=15m # Access token expiry (short-lived)
REFRESH_TOKEN_SECRET=your_refresh_token_secret_here # Separate secret for refresh tokens
REFRESH_TOKEN_EXPIRES_IN=7d # Refresh token expiry (longer-lived)
Step 4: Set Up the Express Server
Create server.js (the entry point):
// server.js
require('dotenv').config();
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const app = express();
// Middleware
app.use(cors());
app.use(express.json()); // Parse JSON request bodies
// Connect to MongoDB
mongoose.connect(process.env.MONGODB_URI)
.then(() => console.log('MongoDB connected'))
.catch(err => console.error('MongoDB connection error:', err));
// Basic route to test the server
app.get('/', (req, res) => {
res.send('JWT Auth Demo API is running!');
});
// Start server
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
Update package.json to use nodemon for auto-reloading:
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
Start the server:
npm run dev
You should see:
Server running on port 5000
MongoDB connected
4. User Registration: Storing Users Securely
Before implementing JWT, we need a way to register users and store their data securely (never store plaintext passwords!).
Step 1: Create a User Model
Define a User schema with Mongoose. Create models/User.js:
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'Name is required']
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: [6, 'Password must be at least 6 characters']
},
refreshTokens: [{ // Store refresh tokens for the user
token: String,
expiresAt: Date
}]
}, { timestamps: true });
// Hash password before saving the user
userSchema.pre('save', async function(next) {
if (!this.isModified('password')) return next(); // Skip if password not changed
// Hash password with cost factor 12
this.password = await bcrypt.hash(this.password, 12);
next();
});
// Method to compare password with hashed password
userSchema.methods.comparePassword = async function(candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
module.exports = mongoose.model('User', userSchema);
Step 2: Create a Registration Endpoint
Add a POST /api/register route in server.js to handle user registration:
// server.js (add this after MongoDB connection)
const User = require('./models/User');
// Register user
app.post('/api/register', async (req, res) => {
try {
const { name, email, password } = req.body;
// Check if user already exists
const existingUser = await User.findOne({ email });
if (existingUser) {
return res.status(400).json({ message: 'User already exists' });
}
// Create new user
const user = await User.create({
name,
email,
password // bcrypt will hash this via the pre-save hook
});
// Return user data (excluding password)
res.status(201).json({
_id: user._id,
name: user.name,
email: user.email
});
} catch (err) {
res.status(500).json({ message: 'Server error', error: err.message });
}
});
Test the Registration Endpoint
Use tools like Postman or curl to send a POST request to http://localhost:5000/api/register with:
{
"name": "John Doe",
"email": "[email protected]",
"password": "password123"
}
You should get a 201 response with user data (password excluded).
5. User Login & JWT Generation
When a user logs in with valid credentials, the server generates a JWT (access token) and a refresh token.
Step 1: Create Login Endpoint
Add a POST /api/login route to validate credentials and issue tokens:
// server.js (add below registration route)
const jwt = require('jsonwebtoken');
// Generate access token
const generateAccessToken = (userId) => {
return jwt.sign({ sub: userId }, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRES_IN
});
};
// Generate refresh token
const generateRefreshToken = (userId) => {
return jwt.sign({ sub: userId }, process.env.REFRESH_TOKEN_SECRET, {
expiresIn: process.env.REFRESH_TOKEN_EXPIRES_IN
});
};
// Login user
app.post('/api/login', async (req, res) => {
try {
const { email, password } = req.body;
// Find user by email
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({ message: 'Invalid credentials' });
}
// Verify password
const isPasswordValid = await user.comparePassword(password);
if (!isPasswordValid) {
return res.status(401).json({ message: 'Invalid credentials' });
}
// Generate tokens
const accessToken = generateAccessToken(user._id);
const refreshToken = generateRefreshToken(user._id);
// Store refresh token in the database (with expiry)
const refreshTokenExpiry = new Date();
refreshTokenExpiry.setDate(refreshTokenExpiry.getDate() + 7); // 7 days
user.refreshTokens.push({
token: refreshToken,
expiresAt: refreshTokenExpiry
});
await user.save();
// Send tokens to client
res.json({
accessToken,
refreshToken,
user: {
_id: user._id,
name: user.name,
email: user.email
}
});
} catch (err) {
res.status(500).json({ message: 'Server error', error: err.message });
}
});
Test the Login Endpoint
Send a POST request to http://localhost:5000/api/login with:
{
"email": "[email protected]",
"password": "password123"
}
You’ll receive an accessToken, refreshToken, and user data.
6. Protecting Routes with JWT Middleware
To restrict access to sensitive routes (e.g., user profile), create middleware to verify the JWT from the Authorization header.
Step 1: Create JWT Verification Middleware
Add a middleware function to server.js to check for valid JWTs:
// server.js (add this as a utility function)
const verifyAccessToken = (req, res, next) => {
try {
// Get token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ message: 'Access denied. No token provided.' });
}
const token = authHeader.split(' ')[1]; // Extract token after "Bearer "
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.userId = decoded.sub; // Attach user ID to request object
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ message: 'Token expired' });
}
res.status(401).json({ message: 'Invalid token' });
}
};
Step 2: Create a Protected Route
Add a GET /api/profile route that requires a valid JWT:
// server.js (add below login route)
// Protected route: Get user profile
app.get('/api/profile', verifyAccessToken, async (req, res) => {
try {
const user = await User.findById(req.userId).select('-password -refreshTokens'); // Exclude sensitive fields
if (!user) {
return res.status(404).json({ message: 'User not found' });
}
res.json(user);
} catch (err) {
res.status(500).json({ message: 'Server error' });
}
});
Test the Protected Route
Send a GET request to http://localhost:5000/api/profile with the JWT in the Authorization header:
Authorization: Bearer <your_access_token>
If the token is valid, you’ll get the user’s profile data. If invalid/expired, you’ll get a 401 error.
7. Refresh Tokens: Enhancing Security
Access tokens should be short-lived (e.g., 15 minutes) to minimize risk if leaked. When they expire, clients use a refresh token (longer-lived) to request a new access token.
Step 1: Create Refresh Token Endpoint
Add a POST /api/refresh-token route to issue new access tokens:
// server.js (add below profile route)
app.post('/api/refresh-token', async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ message: 'Refresh token required' });
}
// Verify refresh token
const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
const userId = decoded.sub;
// Check if refresh token exists in the database
const user = await User.findById(userId);
if (!user) {
return res.status(401).json({ message: 'Invalid refresh token' });
}
const storedToken = user.refreshTokens.find(
rt => rt.token === refreshToken && rt.expiresAt > new Date()
);
if (!storedToken) {
return res.status(401).json({ message: 'Refresh token expired or invalid' });
}
// Generate new access token
const newAccessToken = generateAccessToken(userId);
res.json({ accessToken: newAccessToken });
} catch (err) {
res.status(401).json({ message: 'Invalid refresh token', error: err.message });
}
});
Step 2: Add Logout Endpoint (Invalidate Refresh Tokens)
To log out, remove the refresh token from the database:
// server.js (add below refresh-token route)
app.post('/api/logout', async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ message: 'Refresh token required' });
}
// Remove refresh token from user's array
await User.findOneAndUpdate(
{ 'refreshTokens.token': refreshToken },
{ $pull: { refreshTokens: { token: refreshToken } } }
);
res.json({ message: 'Logged out successfully' });
} catch (err) {
res.status(500).json({ message: 'Server error' });
}
});
8. Security Best Practices
To secure your JWT implementation:
- Use HTTPS: Always transmit tokens over HTTPS to prevent interception.
- Store Tokens Securely:
- Use
HttpOnlyandSecurecookies for tokens (instead oflocalStorage, which is vulnerable to XSS). - Example cookie setup:
res.cookie('accessToken', token, { httpOnly: true, secure: process.env.NODE_ENV === 'production', // HTTPS only in prod maxAge: 15 * 60 * 1000 // 15 minutes });
- Use
- Short-Lived Access Tokens: Use
expclaim (e.g., 15 minutes) to limit exposure if leaked. - Rotate Refresh Tokens: Invalidate old refresh tokens when issuing new ones to prevent replay attacks.
- Keep Secrets Secure: Store
JWT_SECRETandREFRESH_TOKEN_SECRETin environment variables (never hardcode!). - Validate Claims: Always check
expandiatclaims to ensure tokens are not expired or issued prematurely. - Avoid Sensitive Data in Payload: JWT payloads are base64-encoded, not encrypted.
9. Troubleshooting Common Issues
| Issue | Solution |
|---|---|
TokenExpiredError | Check exp claim; use refresh tokens to get a new access token. |
JsonWebTokenError: invalid signature | Ensure the same JWT_SECRET is used for signing and verification. |
No token provided | Verify the client sends the Authorization: Bearer <token> header. |
User not found in protected routes | Ensure req.userId is correctly set in middleware and the user exists. |
10. Conclusion
JWT authentication provides a stateless, scalable way to secure your backend APIs. By following this guide, you’ve learned to:
- Register users and hash passwords securely.
- Generate and verify JWT access tokens.
- Protect routes with middleware.
- Implement refresh tokens for seamless user sessions.
- Apply security best practices to mitigate risks.
With these tools, you can build robust authentication systems for your applications.