Setting Up a Complete JWT Authentication System
Now that we understand what JWTs are and their benefits, let's build a complete authentication system using JWT in a Node.js application. We'll use Express.js for our server, MongoDB for storage, and implement secure practices.
Real-World Context
Imagine you're building a SaaS platform where users need to authenticate across multiple services:
- A React-based dashboard
- A mobile app for on-the-go access
- Several microservices that need to verify the user
JWT authentication provides a unified solution for all these scenarios without requiring multiple logins or complex session management across services.
Project Setup
Required Dependencies
npm init -y
npm install express mongoose jsonwebtoken bcrypt dotenv cors
Dependency Overview
- express: Web server framework
- mongoose: MongoDB ODM for database operations
- jsonwebtoken: Library for creating and verifying JWTs
- bcrypt: For securely hashing passwords
- dotenv: For managing environment variables
- cors: To handle Cross-Origin Resource Sharing
Project Structure
auth-api/
├── .env # Environment variables
├── package.json # Dependencies
├── server.js # Entry point
├── config/
│ └── db.js # Database connection
├── models/
│ ├── User.js # User model
│ └── Token.js # Refresh token model
├── controllers/
│ └── authController.js # Authentication logic
├── middleware/
│ └── auth.js # JWT verification middleware
└── routes/
├── authRoutes.js # Authentication routes
└── userRoutes.js # Protected user routes
Setting Up Environment Variables
Create a .env file in your project root to store sensitive information:
# .env
MONGODB_URI=mongodb://localhost:27017/jwt_auth_demo
JWT_SECRET=your_super_secret_key_should_be_long_and_complex
JWT_ACCESS_EXPIRATION=15m
JWT_REFRESH_EXPIRATION=7d
PORT=3000
Security Note
In a production environment:
- Generate a strong, random JWT secret (at least 32 characters)
- Never commit .env files to version control
- Use different secrets for development and production
- Consider using a secrets management service for production
Database Configuration
Let's set up our MongoDB connection using Mongoose:
// config/db.js
const mongoose = require('mongoose');
require('dotenv').config();
const connectDB = async () => {
try {
await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true
});
console.log('MongoDB connected successfully');
} catch (error) {
console.error('MongoDB connection error:', error.message);
process.exit(1);
}
};
module.exports = connectDB;
User Model
Create a user model to store user information and handle password hashing:
// models/User.js
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');
const userSchema = new mongoose.Schema({
username: {
type: String,
required: true,
unique: true,
trim: true,
minlength: 3
},
email: {
type: String,
required: true,
unique: true,
trim: true,
lowercase: true
},
password: {
type: String,
required: true,
minlength: 6
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user'
}
}, {
timestamps: true
});
// Pre-save middleware to hash password
userSchema.pre('save', async function(next) {
// Only hash the password if it has been modified (or is new)
if (!this.isModified('password')) return next();
try {
// Generate a salt
const salt = await bcrypt.genSalt(10);
// Hash the password along with the new salt
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (error) {
next(error);
}
});
// Method to compare password
userSchema.methods.comparePassword = async function(candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};
const User = mongoose.model('User', userSchema);
module.exports = User;
Security Features
The User model includes several security features:
- Automatic password hashing using bcrypt
- Password comparison method for login
- Validation for username, email, and password
- Role-based access control (user vs. admin)
Token Model for Refresh Tokens
We'll create a model to store refresh tokens, which allows us to invalidate tokens when needed:
// models/Token.js
const mongoose = require('mongoose');
const tokenSchema = new mongoose.Schema({
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
token: {
type: String,
required: true
},
expiryDate: {
type: Date,
required: true
},
createdAt: {
type: Date,
default: Date.now,
expires: '7d' // Token document will be automatically deleted after 7 days
}
});
const Token = mongoose.model('Token', tokenSchema);
module.exports = Token;
Why Store Refresh Tokens?
Unlike access tokens that are verified solely by their signature, refresh tokens are stored in the database because:
- They need to be explicitly revoked when a user logs out
- They can be invalidated if compromised
- You can limit the number of active sessions per user
- They enable tracking of device or IP information for security
Authentication Controller
Next, let's implement the authentication logic:
// controllers/authController.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const Token = require('../models/Token');
require('dotenv').config();
// Helper function to generate tokens
const generateTokens = async (userId) => {
try {
// Create payload for tokens
const payload = { sub: userId };
// Create access token that expires in 15 minutes
const accessToken = jwt.sign(
payload,
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_ACCESS_EXPIRATION || '15m' }
);
// Create refresh token that expires in 7 days
const refreshToken = jwt.sign(
payload,
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_REFRESH_EXPIRATION || '7d' }
);
// Calculate expiry date for database
const refreshExpiry = new Date();
refreshExpiry.setDate(
refreshExpiry.getDate() +
parseInt(process.env.JWT_REFRESH_EXPIRATION || '7d')
);
// Save refresh token in database
await Token.create({
userId,
token: refreshToken,
expiryDate: refreshExpiry
});
return {
accessToken,
refreshToken
};
} catch (error) {
throw error;
}
};
// Register a new user
exports.register = async (req, res) => {
try {
const { username, email, password } = req.body;
// Check if user already exists
const existingUser = await User.findOne({
$or: [{ email }, { username }]
});
if (existingUser) {
return res.status(409).json({
success: false,
message: 'Username or email already exists'
});
}
// Create new user
const newUser = new User({
username,
email,
password
});
await newUser.save();
// Generate tokens
const tokens = await generateTokens(newUser._id);
res.status(201).json({
success: true,
message: 'User registered successfully',
...tokens
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Registration failed',
error: error.message
});
}
};
// Login user
exports.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({
success: false,
message: 'Invalid credentials'
});
}
// Check password
const isPasswordValid = await user.comparePassword(password);
if (!isPasswordValid) {
return res.status(401).json({
success: false,
message: 'Invalid credentials'
});
}
// Generate tokens
const tokens = await generateTokens(user._id);
res.status(200).json({
success: true,
...tokens,
user: {
id: user._id,
username: user.username,
email: user.email,
role: user.role
}
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Login failed',
error: error.message
});
}
};
// Refresh token
exports.refreshToken = async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(401).json({
success: false,
message: 'Refresh token is required'
});
}
// Find token in database
const storedToken = await Token.findOne({ token: refreshToken });
if (!storedToken) {
return res.status(403).json({
success: false,
message: 'Refresh token not found or expired'
});
}
// Verify token
let payload;
try {
payload = jwt.verify(refreshToken, process.env.JWT_SECRET);
} catch (err) {
// Remove invalid token from database
await Token.findByIdAndRemove(storedToken._id);
return res.status(403).json({
success: false,
message: 'Invalid refresh token'
});
}
// Check if token is expired in database
if (storedToken.expiryDate < new Date()) {
// Remove expired token
await Token.findByIdAndRemove(storedToken._id);
return res.status(403).json({
success: false,
message: 'Refresh token expired'
});
}
// Create new access token
const newAccessToken = jwt.sign(
{ sub: payload.sub },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_ACCESS_EXPIRATION || '15m' }
);
res.status(200).json({
success: true,
accessToken: newAccessToken
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Token refresh failed',
error: error.message
});
}
};
// Logout user
exports.logout = async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({
success: false,
message: 'Refresh token is required'
});
}
// Remove token from database
await Token.findOneAndRemove({ token: refreshToken });
res.status(200).json({
success: true,
message: 'Logged out successfully'
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Logout failed',
error: error.message
});
}
};
Authentication Middleware
Create middleware to verify JWT access tokens for protected routes:
// middleware/auth.js
const jwt = require('jsonwebtoken');
require('dotenv').config();
exports.verifyToken = (req, res, next) => {
// Get token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
message: 'Access token is required'
});
}
const token = authHeader.split(' ')[1];
try {
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Add user ID to request
req.userId = decoded.sub;
next();
} catch (error) {
let message = 'Invalid token';
if (error.name === 'TokenExpiredError') {
message = 'Token expired';
}
return res.status(401).json({
success: false,
message
});
}
};
// Role-based access control middleware
exports.checkRole = (role) => {
return async (req, res, next) => {
try {
const User = require('../models/User');
const user = await User.findById(req.userId);
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found'
});
}
if (user.role !== role) {
return res.status(403).json({
success: false,
message: 'Access denied'
});
}
next();
} catch (error) {
res.status(500).json({
success: false,
message: 'Authorization failed',
error: error.message
});
}
};
};
Using the Middleware
The auth middleware can be used in two ways:
- Basic authentication:
router.get('/profile', verifyToken, userController.getProfile); - Role-based access:
router.get('/admin-panel', verifyToken, checkRole('admin'), adminController.getPanel);
Authentication Routes
Set up the routes for authentication operations:
// routes/authRoutes.js
const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');
// Register new user
router.post('/register', authController.register);
// Login user
router.post('/login', authController.login);
// Refresh access token
router.post('/refresh-token', authController.refreshToken);
// Logout user
router.post('/logout', authController.logout);
module.exports = router;
Protected User Routes
Create protected routes that require authentication:
// routes/userRoutes.js
const express = require('express');
const router = express.Router();
const { verifyToken, checkRole } = require('../middleware/auth');
// Protected route - Get user profile
router.get('/profile', verifyToken, async (req, res) => {
try {
const User = require('../models/User');
const user = await User.findById(req.userId).select('-password');
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found'
});
}
res.status(200).json({
success: true,
user
});
} catch (error) {
res.status(500).json({
success: false,
message: 'Error fetching profile',
error: error.message
});
}
});
// Admin-only route
router.get('/admin', verifyToken, checkRole('admin'), (req, res) => {
res.status(200).json({
success: true,
message: 'Admin access granted',
data: {
sensitiveInfo: 'This data is only accessible to admins'
}
});
});
module.exports = router;
Server Setup
Now, let's set up the Express server:
// server.js
const express = require('express');
const cors = require('cors');
const connectDB = require('./config/db');
require('dotenv').config();
// Import routes
const authRoutes = require('./routes/authRoutes');
const userRoutes = require('./routes/userRoutes');
// Initialize express app
const app = express();
// Connect to database
connectDB();
// Middleware
app.use(cors());
app.use(express.json());
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
// Root route
app.get('/', (req, res) => {
res.json({ message: 'Welcome to JWT Authentication API' });
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
success: false,
message: 'Something went wrong',
error: process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message
});
});
// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
JWT Authentication Flow
Storing JWTs on the Client
When implementing JWT authentication, secure storage on the client side is crucial:
| Storage Method | Security | Accessibility | Best For |
|---|---|---|---|
| HttpOnly Cookie | High (protected from XSS) | Automatic with requests | Refresh tokens |
| localStorage | Low (vulnerable to XSS) | Easy, persists on refresh | Development only |
| sessionStorage | Low (vulnerable to XSS) | Cleared on tab close | Short lived access |
| In-memory (React state) | High (cleared on refresh) | Lost on page refresh | Access tokens |
Recommended Approach
A common secure pattern is:
- Store access token in memory (React state/Redux)
- Store refresh token in an HttpOnly cookie
- Implement a token refresh mechanism that activates when the access token expires
Client-Side Implementation with React
Here's a basic example of implementing the client side of JWT authentication in React:
// AuthContext.js
import React, { createContext, useState, useContext, useEffect } from 'react';
import axios from 'axios';
const API_URL = 'http://localhost:3000/api';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [currentUser, setCurrentUser] = useState(null);
const [accessToken, setAccessToken] = useState(null);
const [loading, setLoading] = useState(true);
// Initialize auth state from localStorage
useEffect(() => {
const user = localStorage.getItem('user');
const token = localStorage.getItem('accessToken');
if (user && token) {
setCurrentUser(JSON.parse(user));
setAccessToken(token);
// Set axios default header
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
setLoading(false);
}, []);
// Configure axios interceptor for token refresh
useEffect(() => {
const interceptor = axios.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If error is not 401 or request already tried, reject
if (error.response.status !== 401 || originalRequest._retry) {
return Promise.reject(error);
}
originalRequest._retry = true;
try {
// Get refresh token from localStorage
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
throw new Error('No refresh token');
}
// Request new access token
const response = await axios.post(`${API_URL}/auth/refresh-token`, {
refreshToken
});
const newAccessToken = response.data.accessToken;
// Update token in state and localStorage
setAccessToken(newAccessToken);
localStorage.setItem('accessToken', newAccessToken);
// Update authorization header
axios.defaults.headers.common['Authorization'] = `Bearer ${newAccessToken}`;
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
// Retry original request
return axios(originalRequest);
} catch (refreshError) {
// If refresh fails, log out user
logout();
return Promise.reject(refreshError);
}
}
);
return () => {
axios.interceptors.response.eject(interceptor);
};
}, []);
// Register function
const register = async (username, email, password) => {
try {
const response = await axios.post(`${API_URL}/auth/register`, {
username,
email,
password
});
const { accessToken, refreshToken, user } = response.data;
// Save to state and localStorage
setCurrentUser(user);
setAccessToken(accessToken);
localStorage.setItem('user', JSON.stringify(user));
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
// Set default authorization header
axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
return user;
} catch (error) {
throw error.response.data;
}
};
// Login function
const login = async (email, password) => {
try {
const response = await axios.post(`${API_URL}/auth/login`, {
email,
password
});
const { accessToken, refreshToken, user } = response.data;
// Save to state and localStorage
setCurrentUser(user);
setAccessToken(accessToken);
localStorage.setItem('user', JSON.stringify(user));
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
// Set default authorization header
axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
return user;
} catch (error) {
throw error.response.data;
}
};
// Logout function
const logout = async () => {
try {
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
await axios.post(`${API_URL}/auth/logout`, { refreshToken });
}
} catch (error) {
console.error('Logout error:', error);
} finally {
// Clear state and localStorage
setCurrentUser(null);
setAccessToken(null);
localStorage.removeItem('user');
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
// Remove authorization header
delete axios.defaults.headers.common['Authorization'];
}
};
const value = {
currentUser,
accessToken,
loading,
register,
login,
logout
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
// Custom hook to use the auth context
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
Using the Auth Context
// App.js
import React from 'react';
import { BrowserRouter as Router, Route, Switch, Redirect } from 'react-router-dom';
import { AuthProvider, useAuth } from './AuthContext';
import Login from './pages/Login';
import Register from './pages/Register';
import Dashboard from './pages/Dashboard';
import Profile from './pages/Profile';
import AdminPanel from './pages/AdminPanel';
// Private route component
const PrivateRoute = ({ component: Component, ...rest }) => {
const { currentUser, loading } = useAuth();
if (loading) {
return Loading...;
}
return (
<Route
{...rest}
render={(props) =>
currentUser ? (
<Component {...props} />
) : (
<Redirect to="/login" />
)
}
/>
);
};
// Admin route with role check
const AdminRoute = ({ component: Component, ...rest }) => {
const { currentUser, loading } = useAuth();
if (loading) {
return Loading...;
}
return (
<Route
{...rest}
render={(props) =>
currentUser && currentUser.role === 'admin' ? (
<Component {...props} />
) : (
<Redirect to="/dashboard" />
)
}
/>
);
};
const App = () => {
return (
<AuthProvider>
<Router>
<Switch>
<Route exact path="/login" component={Login} />
<Route exact path="/register" component={Register} />
<PrivateRoute exact path="/dashboard" component={Dashboard} />
<PrivateRoute exact path="/profile" component={Profile} />
<AdminRoute exact path="/admin" component={AdminPanel} />
<Redirect from="/" to="/dashboard" />
</Switch>
</Router>
</AuthProvider>
);
};
export default App;
Login Component Example
// pages/Login.js
import React, { useState } from 'react';
import { useHistory, Link } from 'react-router-dom';
import { useAuth } from '../AuthContext';
const Login = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const history = useHistory();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
setLoading(true);
await login(email, password);
history.push('/dashboard');
} catch (error) {
setError(error.message || 'Failed to log in');
} finally {
setLoading(false);
}
};
return (
<div className="login-container">
<h2>Login</h2>
{error && <div className="error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label>Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="form-group">
<label>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
<p>
Don't have an account? <Link to="/register">Register</Link>
&
export default Login;
Security Best Practices
JWT Security Checklist
- Use HTTPS: Always transmit JWTs over HTTPS connections
- Set proper expiration: Short-lived access tokens (15-60 min)
- Implement refresh tokens: With longer expiration but revocable
- Store securely: Memory for access tokens, HttpOnly cookies for refresh tokens
- Keep payload minimal: Don't include sensitive data in tokens
- Use strong secrets: Long, random strings generated with a CSPRNG
- Whitelist algorithms: Prevent algorithm switching attacks
- Validate all tokens: Always verify signature and expiration
- Include issuer (iss) and audience (aud): For multi-service environments
- Use CSRF protection: When using cookies for tokens
- Implement rate limiting: Prevent brute-force attacks
Real-World Security Vulnerabilities to Avoid
- JWT None Algorithm: Some libraries may accept tokens with "none" algorithm
- Algorithm Switching: Attackers changing alg from RS256 to HS256
- Weak Secret Keys: Using predictable or short keys
- Not Validating Issuer/Audience: Accepting tokens meant for other services
- Missing Expiration: Tokens without exp claim never expire
- XSS via localStorage: Storing tokens in vulnerable client storage
- Token Leakage: Exposing tokens in URLs, logs, or referrer headers
Advanced JWT Features
Using Different Algorithms
// Using RSA (asymmetric) keys
const fs = require('fs');
const jwt = require('jsonwebtoken');
// Read private and public keys
const privateKey = fs.readFileSync('private.key');
const publicKey = fs.readFileSync('public.key');
// Sign with private key
const token = jwt.sign({ sub: 'user123' }, privateKey, {
algorithm: 'RS256',
expiresIn: '1h'
});
// Verify with public key
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'] // Whitelist algorithms
});
Token Blacklisting
// Redis implementation for blacklisting tokens
const redis = require('redis');
const util = require('util');
const client = redis.createClient();
// Promisify Redis commands
const setAsync = util.promisify(client.set).bind(client);
const getAsync = util.promisify(client.get).bind(client);
const existsAsync = util.promisify(client.exists).bind(client);
// Blacklist a token
const blacklistToken = async (token, expiryTime) => {
// Store token in Redis with expiry time
await setAsync(`bl_${token}`, 'true', 'EX', expiryTime);
};
// Check if token is blacklisted
const isTokenBlacklisted = async (token) => {
const result = await existsAsync(`bl_${token}`);
return result === 1;
};
// Updated verify middleware
const verifyToken = async (req, res, next) => {
const token = req.headers.authorization.split(' ')[1];
// Check blacklist first
if (await isTokenBlacklisted(token)) {
return res.status(401).json({
success: false,
message: 'Token has been revoked'
});
}
// Continue with verification
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.userId = decoded.sub;
next();
} catch (error) {
res.status(401).json({
success: false,
message: error.name === 'TokenExpiredError' ? 'Token expired' : 'Invalid token'
});
}
};
Practice Activities
Activity 1: Complete JWT Authentication System
Build the complete authentication system described in this lecture:
- Set up the Node.js/Express server
- Implement user registration and login
- Add refresh token functionality
- Create protected routes with role-based access
- Test all endpoints with Postman or similar tool
Activity 2: React Frontend Integration
Integrate the JWT authentication with a React frontend:
- Create login and registration forms
- Implement the AuthContext for state management
- Add protected routes with role-based access
- Set up automatic token refresh
- Create a user profile page that shows user data
Activity 3: Security Enhancement
Enhance the security of your JWT implementation:
- Implement token blacklisting for logout
- Add rate limiting to prevent brute force attacks
- Implement secure cookie storage for refresh tokens
- Add CSRF protection
- Switch from symmetric (HS256) to asymmetric (RS256) signing
Additional Resources
Summary
- Implemented a complete JWT authentication system with Node.js and Express
- Created models for users and refresh tokens
- Built authentication controllers for registration, login, and token refresh
- Developed middleware for token verification and role-based access control
- Integrated JWT authentication with a React frontend
- Discussed security best practices and common vulnerabilities
- Explored advanced features like asymmetric keys and token blacklisting
Next, we'll cover token refresh strategies and patterns for securely maintaining user sessions.