Protecting Routes with Middleware

Difficulty: Intermediate

Once you have authentication in place (whether JWT or sessions), the next step is protecting your routes so that only authorized users can access them. In Express, this is done through middleware functions that run before your route handler. An auth middleware verifies the user's identity, and a role middleware checks if the authenticated user has the right permissions.

The auth middleware pattern is straightforward: extract credentials (token or session), verify them, attach user info to the request object, and call next(). If verification fails, return a 401 Unauthorized response without calling next(). This pattern keeps your route handlers clean - they can assume req.user exists and focus on business logic.

Role-based access control (RBAC) adds a second layer. After authentication confirms who the user is, authorization determines what they can do. The `requireRole` pattern is a middleware factory - a function that takes allowed roles as arguments and returns a middleware function. This returned middleware checks if req.user.role matches one of the allowed roles and either calls next() or returns 403 Forbidden.

Middleware chaining is what makes this system elegant. Express lets you pass multiple middleware functions to a route definition: `app.get('/admin', auth, requireRole('ADMIN'), handler)`. Each middleware runs in order, and if any one stops the chain (by sending a response without calling next), the subsequent ones do not execute. This composition pattern allows fine-grained access control without code duplication.

For real-world applications, you often need more nuanced authorization. A recruiter should only edit their own job postings, not other recruiters' postings. A student should only see their own applications. This resource-level authorization is typically handled in the route handler or service layer, where you check if the authenticated user owns the requested resource. The middleware layer handles identity and role verification; the handler layer handles ownership checks.

Code examples

Auth Middleware and Role-Based Access Control

const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET || 'dev-secret';

// Authentication middleware
function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Authentication required' });
  }

  try {
    const token = authHeader.split(' ')[1];
    const decoded = jwt.verify(token, SECRET);
    req.user = { id: decoded.userId, email: decoded.email, role: decoded.role };
    next();
  } catch (error) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// Role authorization middleware factory
function requireRole(...allowedRoles) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required' });
    }
    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({
        error: 'Insufficient permissions',
        required: allowedRoles,
        current: req.user.role
      });
    }
    next();
  };
}

console.log('authMiddleware type:', typeof authMiddleware);
console.log('requireRole type:', typeof requireRole);
console.log('requireRole("ADMIN") type:', typeof requireRole('ADMIN'));

authMiddleware verifies the JWT and sets req.user. requireRole is a factory that returns a middleware checking if the user's role is in the allowed list. Using rest parameters (...allowedRoles) lets you specify multiple roles like requireRole('ADMIN', 'RECRUITER').

Applying Middleware to Routes

const express = require('express');
const app = express();
app.use(express.json());

// Assume authMiddleware and requireRole from previous example

// Public route - no middleware
app.get('/api/jobs', (req, res) => {
  res.json({ jobs: ['Frontend Dev', 'Backend Dev'] });
});

// Authenticated route - any logged-in user
app.get('/api/profile', authMiddleware, (req, res) => {
  res.json({ user: req.user });
});

// Role-specific routes
app.post('/api/jobs',
  authMiddleware,
  requireRole('RECRUITER', 'ADMIN'),
  (req, res) => {
    res.json({ message: 'Job created', by: req.user.email });
  }
);

app.delete('/api/users/:id',
  authMiddleware,
  requireRole('ADMIN'),
  (req, res) => {
    res.json({ message: `User ${req.params.id} deleted` });
  }
);

// Apply auth to all routes in a router
const adminRouter = express.Router();
adminRouter.use(authMiddleware);
adminRouter.use(requireRole('ADMIN'));

adminRouter.get('/dashboard', (req, res) => {
  res.json({ stats: { users: 100, jobs: 50 } });
});

adminRouter.get('/logs', (req, res) => {
  res.json({ logs: ['action1', 'action2'] });
});

app.use('/api/admin', adminRouter);

console.log('Routes configured with middleware chains');

This shows three patterns: per-route middleware (authMiddleware on individual routes), middleware chaining (auth + role on the same route), and router-level middleware (all admin routes get auth + admin role check automatically).

Optional Auth and Resource Ownership

const jwt = require('jsonwebtoken');
const SECRET = 'dev-secret';

// Optional auth - attaches user if token present, but does not reject
function optionalAuth(req, res, next) {
  const authHeader = req.headers.authorization;
  if (authHeader && authHeader.startsWith('Bearer ')) {
    try {
      const token = authHeader.split(' ')[1];
      req.user = jwt.verify(token, SECRET);
    } catch (err) {
      // Token invalid, but that is okay - continue without user
    }
  }
  next();
}

// Resource ownership check
function requireOwnership(resourceUserIdFn) {
  return (req, res, next) => {
    const resourceUserId = resourceUserIdFn(req);
    if (req.user.role === 'ADMIN' || req.user.id === resourceUserId) {
      return next();
    }
    return res.status(403).json({ error: 'You can only access your own resources' });
  };
}

// Usage:
// app.get('/api/jobs', optionalAuth, handler);
//   - Shows public data, plus user-specific data if logged in
//
// app.put('/api/applications/:id',
//   authMiddleware,
//   requireOwnership(req => getApplication(req.params.id).studentId),
//   handler
// );

console.log('Optional auth and ownership middleware defined');

optionalAuth allows public access while enriching the request with user data when available. requireOwnership is a factory that takes a function to extract the resource owner's ID and compares it with the authenticated user, with an admin override.

Key points

Concepts covered

auth middleware, role-based access, requireRole, route guards, middleware chaining