Building Secure Web Authentication - Part 3: Route Protection & Middleware

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 - requireAuth and optionalAuth patterns
  • 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.