In Part 2, we built login and logout endpoints. But how do we actually protect routes that require authentication? That's where middleware comes in.
Middleware lets us add authentication checks to any route without repeating code. By the end of this tutorial, you'll have reusable authentication guards that keep your API endpoints secure.
What We're Building
We'll create middleware that:
- ✅ Extracts JWT tokens from cookies
- ✅ Verifies token validity
- ✅ Attaches user info to requests
- ✅ Handles authentication errors gracefully
- ✅ Works with TypeScript types
Then we'll apply it to protect API routes and integrate with a frontend.
Time estimate: 30-45 minutes
Understanding Express Middleware
Before we dive in, let's understand how middleware works:
// Without middleware (repetitive):
app.get('/api/profile', (req, res) => {
const token = req.cookies.auth_token;
if (!token) return res.status(401).json({ error: 'Not authenticated' });
const user = verifyToken(token);
if (!user) return res.status(401).json({ error: 'Invalid token' });
// Finally, the actual route logic
res.json({ profile: user });
});
app.get('/api/settings', (req, res) => {
const token = req.cookies.auth_token;
if (!token) return res.status(401).json({ error: 'Not authenticated' });
const user = verifyToken(token);
if (!user) return res.status(401).json({ error: 'Invalid token' });
// Duplicated authentication code!
res.json({ settings: {} });
});
With middleware (DRY principle):
// Define once
const requireAuth = (req, res, next) => {
const token = req.cookies.auth_token;
if (!token) return res.status(401).json({ error: 'Not authenticated' });
const user = verifyToken(token);
if (!user) return res.status(401).json({ error: 'Invalid token' });
req.user = user; // Attach user to request
next(); // Continue to route handler
};
// Use everywhere
app.get('/api/profile', requireAuth, (req, res) => {
res.json({ profile: req.user }); // User is already verified!
});
app.get('/api/settings', requireAuth, (req, res) => {
res.json({ settings: {} }); // No duplicate code!
});
Middleware flow:
Request
↓
Middleware 1 (express.json)
↓
Middleware 2 (cookieParser)
↓
Middleware 3 (requireAuth) ← Our authentication middleware
↓
Route Handler
↓
Response
Each middleware can:
- Modify the request/response
- Call
next()to continue to the next middleware - Send a response and stop the chain
TypeScript Type Definitions
First, let's extend Express types to include our user data.
Create src/types/express.d.ts:
import { DecodedToken } from '../utils/jwt';
// Extend Express Request interface to include user
declare global {
namespace Express {
interface Request {
user?: DecodedToken; // Optional because not all routes need auth
}
}
}
This lets TypeScript know that req.user might exist and what type it is.
Creating Authentication Middleware
Create src/middleware/auth.ts:
import { Request, Response, NextFunction } from 'express';
import { verifyToken, DecodedToken } from '../utils/jwt';
/**
* Middleware that requires authentication
* Verifies JWT token from cookie and attaches user to request
*
* Usage:
* app.get('/protected', requireAuth, (req, res) => {
* console.log(req.user); // User is available
* });
*/
export function requireAuth(
req: Request,
res: Response,
next: NextFunction
): void {
try {
// Extract token from cookie
const token = req.cookies.auth_token;
if (!token) {
res.status(401).json({
error: 'Authentication required',
message: 'No authentication token provided',
});
return;
}
// Verify token
const decoded = verifyToken(token);
if (!decoded) {
// Token is invalid or expired
res.clearCookie('auth_token'); // Clean up invalid token
res.status(401).json({
error: 'Authentication failed',
message: 'Invalid or expired token',
});
return;
}
// Attach user info to request
req.user = decoded;
// Continue to next middleware/route handler
next();
} catch (error) {
console.error('Authentication middleware error:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Authentication check failed',
});
}
}
/**
* Middleware that optionally checks authentication
* Attaches user to request if token is valid, but doesn't fail if not
*
* Usage:
* app.get('/public-or-private', optionalAuth, (req, res) => {
* if (req.user) {
* // User is logged in
* } else {
* // User is not logged in (that's okay)
* }
* });
*/
export function optionalAuth(
req: Request,
res: Response,
next: NextFunction
): void {
try {
const token = req.cookies.auth_token;
if (token) {
const decoded = verifyToken(token);
if (decoded) {
req.user = decoded;
}
}
// Always continue, even if no token or invalid token
next();
} catch (error) {
// Log error but don't fail the request
console.error('Optional auth middleware error:', error);
next();
}
}
Key differences:
| Middleware | Requires Token | Fails Without Token | Use Case |
|---|---|---|---|
requireAuth |
Yes | Yes (401 error) | Protected routes |
optionalAuth |
No | No (continues) | Mixed public/private content |
Creating Protected Routes
Create src/routes/protected.ts:
import express, { Request, Response } from 'express';
import { requireAuth, optionalAuth } from '../middleware/auth';
const router = express.Router();
/**
* GET /api/protected/profile
* Requires authentication - returns user profile
*/
router.get('/profile', requireAuth, (req: Request, res: Response) => {
// req.user is guaranteed to exist (requireAuth ensures this)
res.json({
success: true,
profile: {
id: req.user!.userId,
username: req.user!.username,
// In production, fetch additional data from database
},
});
});
/**
* GET /api/protected/settings
* Requires authentication - returns user settings
*/
router.get('/settings', requireAuth, (req: Request, res: Response) => {
res.json({
success: true,
settings: {
userId: req.user!.userId,
theme: 'dark',
notifications: true,
// In production, fetch from database
},
});
});
/**
* PUT /api/protected/settings
* Requires authentication - updates user settings
*/
router.put('/settings', requireAuth, (req: Request, res: Response) => {
const { theme, notifications } = req.body;
// In production, update database
console.log(`Updating settings for user ${req.user!.userId}:`, {
theme,
notifications,
});
res.json({
success: true,
message: 'Settings updated',
});
});
/**
* GET /api/protected/feed
* Optional authentication - shows personalized content if logged in
*/
router.get('/feed', optionalAuth, (req: Request, res: Response) => {
if (req.user) {
// User is logged in - show personalized feed
res.json({
success: true,
feed: [
{ id: 1, title: 'Welcome back!', personalized: true },
{ id: 2, title: 'Based on your interests...', personalized: true },
],
user: req.user.username,
});
} else {
// User is not logged in - show public feed
res.json({
success: true,
feed: [
{ id: 1, title: 'Latest news', personalized: false },
{ id: 2, title: 'Public updates', personalized: false },
],
user: null,
});
}
});
export default router;
Protecting Multiple Routes at Once
You can apply middleware to entire route groups:
import express from 'express';
import { requireAuth } from './middleware/auth';
const app = express();
// Public routes (no auth required)
app.get('/api/public/posts', (req, res) => {
res.json({ posts: [] });
});
// Protect all routes under /api/admin
app.use('/api/admin', requireAuth);
app.get('/api/admin/users', (req, res) => {
// Already protected by middleware above
res.json({ users: [] });
});
app.delete('/api/admin/users/:id', (req, res) => {
// Also protected
res.json({ success: true });
});
Visual structure:
/api/public/* → No authentication
/api/auth/* → No authentication (login/logout)
/api/protected/* → Requires authentication
/api/admin/* → Requires authentication
Update Server
Update src/server.ts to include protected routes:
import express from 'express';
import cookieParser from 'cookie-parser';
import { config } from './config/env';
import authRoutes from './routes/auth';
import protectedRoutes from './routes/protected';
const app = express();
// Middleware
app.use(express.json());
app.use(cookieParser());
// Public routes
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
// Authentication routes (public)
app.use('/api/auth', authRoutes);
// Protected routes (require authentication)
app.use('/api/protected', protectedRoutes);
// 404 handler
app.use((req, res) => {
res.status(404).json({
error: 'Not found',
message: `Route ${req.method} ${req.path} not found`,
});
});
// Start server
app.listen(config.port, () => {
console.log(`🚀 Server running on http://localhost:${config.port}`);
console.log(`🔐 Protected routes available at /api/protected/*`);
});
Testing Protected Routes
Test 1: Access Without Authentication
curl http://localhost:3000/api/protected/profile
Expected response (401 Unauthorized):
{
"error": "Authentication required",
"message": "No authentication token provided"
}
Test 2: Login and Access Protected Route
# Step 1: Login
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"changeme123"}' \
-c cookies.txt
# Step 2: Access protected route with cookie
curl http://localhost:3000/api/protected/profile \
-b cookies.txt
Expected response (200 OK):
{
"success": true,
"profile": {
"id": "1",
"username": "admin"
}
}
Test 3: Optional Authentication
# Without authentication
curl http://localhost:3000/api/protected/feed
# Returns public feed
# With authentication
curl http://localhost:3000/api/protected/feed -b cookies.txt
# Returns personalized feed
Frontend Integration
Now let's see how to integrate this with a frontend application.
React Example
// api/auth.ts - API client
const API_BASE = 'http://localhost:3000';
export async function login(username: string, password: string) {
const response = await fetch(`${API_BASE}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // Important: send cookies
body: JSON.stringify({ username, password }),
});
if (!response.ok) {
throw new Error('Login failed');
}
return response.json();
}
export async function logout() {
const response = await fetch(`${API_BASE}/api/auth/logout`, {
method: 'POST',
credentials: 'include',
});
return response.json();
}
export async function checkAuth() {
const response = await fetch(`${API_BASE}/api/auth/check`, {
credentials: 'include',
});
return response.json();
}
export async function getProfile() {
const response = await fetch(`${API_BASE}/api/protected/profile`, {
credentials: 'include',
});
if (!response.ok) {
throw new Error('Failed to fetch profile');
}
return response.json();
}
// LoginForm.tsx - Login component
import { useState } from 'react';
import { login } from './api/auth';
export function LoginForm() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await login(username, password);
// Redirect to dashboard
window.location.href = '/dashboard';
} catch (err) {
setError('Invalid credentials');
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
{error && <div className="error">{error}</div>}
<button type="submit">Login</button>
</form>
);
}
// AuthContext.tsx - Global auth state
import { createContext, useContext, useEffect, useState } from 'react';
import { checkAuth, logout } from './api/auth';
interface AuthContextType {
user: { id: string; username: string } | null;
loading: boolean;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
checkAuth()
.then((data) => {
if (data.authenticated) {
setUser(data.user);
}
})
.finally(() => setLoading(false));
}, []);
const handleLogout = async () => {
await logout();
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, logout: handleLogout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
// ProtectedRoute.tsx - Route guard component
import { Navigate } from 'react-router-dom';
import { useAuth } from './AuthContext';
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
if (loading) {
return <div>Loading...</div>;
}
if (!user) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
// App.tsx - Usage
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider } from './AuthContext';
import { ProtectedRoute } from './ProtectedRoute';
import { LoginForm } from './LoginForm';
import { Dashboard } from './Dashboard';
function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginForm />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
Important: CORS Configuration
If your frontend runs on a different port (e.g., React on localhost:5173), you need CORS:
// src/server.ts
import cors from 'cors';
app.use(cors({
origin: 'http://localhost:5173', // Your frontend URL
credentials: true, // Allow cookies
}));
Install the package:
npm install cors
npm install -D @types/cors
Error Handling Best Practices
Consistent Error Format
// src/utils/errors.ts
export class AuthError extends Error {
constructor(
message: string,
public statusCode: number = 401,
public code?: string
) {
super(message);
this.name = 'AuthError';
}
}
// In middleware
if (!token) {
throw new AuthError('Authentication required', 401, 'NO_TOKEN');
}
// Error handler middleware
app.use((err, req, res, next) => {
if (err instanceof AuthError) {
return res.status(err.statusCode).json({
error: err.message,
code: err.code,
});
}
// Generic error
res.status(500).json({
error: 'Internal server error',
});
});
Logging Authentication Events
// src/middleware/auth.ts
import winston from 'winston';
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'auth.log' }),
],
});
export function requireAuth(req, res, next) {
const token = req.cookies.auth_token;
if (!token) {
logger.warn('Authentication failed: No token', {
ip: req.ip,
path: req.path,
timestamp: new Date().toISOString(),
});
// ... rest of error handling
}
// ... verify token
logger.info('Authentication successful', {
userId: decoded.userId,
ip: req.ip,
timestamp: new Date().toISOString(),
});
next();
}
Common Issues and Solutions
Issue 1: CORS Error - "Credentials flag is true, but Access-Control-Allow-Credentials is not"
Cause: Missing CORS configuration for credentials
Solution:
app.use(cors({
origin: 'http://localhost:5173',
credentials: true, // Add this
}));
Issue 2: Cookies Not Sent from Frontend
Cause: Missing credentials: 'include' in fetch
Solution:
fetch('http://localhost:3000/api/protected/profile', {
credentials: 'include', // Add this
});
Issue 3: TypeScript Error - "Property 'user' does not exist on type 'Request'"
Cause: Missing type declaration
Solution: Create src/types/express.d.ts as shown earlier
Security Considerations
Rate Limiting
Prevent brute force attacks on protected routes:
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Max 100 requests per window
message: 'Too many requests, please try again later',
});
app.use('/api/protected', limiter);
Request Origin Validation
export function requireAuth(req, res, next) {
// Check origin header
const origin = req.get('origin');
const allowedOrigins = ['http://localhost:5173', 'https://yourdomain.com'];
if (origin && !allowedOrigins.includes(origin)) {
return res.status(403).json({
error: 'Forbidden',
message: 'Invalid origin',
});
}
// ... rest of authentication
}
Summary
You've learned:
- ✅ Express middleware - How middleware works and the request lifecycle
- ✅ Authentication middleware -
requireAuthandoptionalAuthpatterns - ✅ TypeScript types - Extending Express Request with user data
- ✅ Protected routes - Applying middleware to routes and route groups
- ✅ Frontend integration - React example with auth context and protected routes
- ✅ Error handling - Consistent error responses and logging
- ✅ Security - CORS, rate limiting, origin validation
What's Next?
In Part 4, we'll implement advanced features:
- Remember Me functionality
- Multi-user support with database
- Protected content sections
- Token refresh mechanism
- Role-based access control (RBAC)
Continue the series: Part 4: Advanced Features →
Questions? Middleware is the backbone of route protection. Make sure you understand the flow before adding more complexity.