Protecting Express Routes with the protect Middleware

The protect middleware is the gateway to every authenticated route in your Express API. It runs before route handlers for protected endpoints, extracts the JWT from the Authorization header, verifies it, fetches the current user from MongoDB, and attaches them to req.user. The route handler then has access to the authenticated user without repeating any auth logic. In this lesson you will build the complete protect middleware, an authorize factory for role-based access control, and verify every scenario โ€” valid token, expired token, missing token, and insufficient permissions.

The protect Middleware โ€” Complete Implementation

// server/src/middleware/auth.js
const User              = require('../models/User');
const AppError          = require('../utils/AppError');
const { verifyAccessToken } = require('../utils/jwt');

const protect = async (req, res, next) => {
  try {
    // โ”€โ”€ 1. Extract token from Authorization header โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    const authHeader = req.headers.authorization;
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      throw new AppError('No token provided โ€” please log in', 401);
    }
    const token = authHeader.split(' ')[1]; // 'Bearer <token>' โ†’ '<token>'

    // โ”€โ”€ 2. Verify the token โ€” throws if invalid or expired โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    const decoded = verifyAccessToken(token); // throws TokenExpiredError or JsonWebTokenError

    // โ”€โ”€ 3. Fetch the current user from MongoDB โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    // Always fetch from DB โ€” the token payload may be stale (role changed etc.)
    const user = await User.findById(decoded.id).select('-password');
    if (!user) {
      // Token was valid but the user was deleted after it was issued
      throw new AppError('User no longer exists', 401);
    }

    // โ”€โ”€ 4. Check if user changed password after token was issued โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    // (Optional but recommended for high-security apps)
    // if (user.passwordChangedAt && decoded.iat < user.passwordChangedAt.getTime() / 1000) {
    //   throw new AppError('Password changed โ€” please log in again', 401);
    // }

    // โ”€โ”€ 5. Attach user to request โ€” available in all downstream handlers โ”€โ”€โ”€
    req.user = user;
    next(); // proceed to the route handler

  } catch (err) {
    next(err); // passes TokenExpiredError / JsonWebTokenError / AppError to errorHandler
  }
};

module.exports = protect;
Note: The convention for sending JWTs is the Authorization: Bearer <token> HTTP header. The Bearer scheme is specified in RFC 6750 for OAuth 2.0 but is also used universally for JWT auth. Always validate that the header starts with 'Bearer ' (with a space) before splitting โ€” a malformed header without the prefix should return 401, not a crash from split(' ')[1] returning undefined.
Tip: Always fetch the user from MongoDB in the protect middleware rather than trusting the token payload entirely. A token payload might be stale โ€” the user’s role could have changed, or the account could have been deactivated after the token was issued. Fetching from the database ensures you always have the latest user data. The extra DB query is worth the accuracy for most applications.
Warning: Never return different error messages that distinguish between “no token”, “expired token”, and “invalid token” in production API responses โ€” this leaks information about the auth system. In development, descriptive messages help debugging. In production, all three can return a generic “Authentication required” message. Your global errorHandler already handles this with the NODE_ENV check from Chapter 13.

The authorize Middleware โ€” Role-Based Access Control

// server/src/middleware/authorize.js
// Factory function: returns middleware that allows only specified roles
const AppError = require('../utils/AppError');

const authorize = (...allowedRoles) => {
  return (req, res, next) => {
    // protect must run before authorize โ€” req.user must be set
    if (!req.user) {
      return next(new AppError('Not authenticated', 401));
    }

    if (!allowedRoles.includes(req.user.role)) {
      return next(
        new AppError(
          `Role '${req.user.role}' is not authorised to access this route`,
          403
        )
      );
    }

    next(); // user has the required role โ€” proceed
  };
};

module.exports = authorize;

// โ”€โ”€ Usage in routes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
// server/src/routes/admin.js
const protect   = require('../middleware/auth');
const authorize = require('../middleware/authorize');

// Any authenticated user
router.get('/me', protect, getMe);

// Only editors and admins
router.post('/posts', protect, authorize('editor', 'admin'), createPost);

// Admin only
router.delete('/users/:id', protect, authorize('admin'), deleteUser);
router.get('/stats',        protect, authorize('admin'), getAdminStats);

Owner-Only Access โ€” Beyond Role Checks

// Some resources are only accessible to their owner (or an admin)
// Check ownership inside the controller โ€” not in middleware

const updatePost = asyncHandler(async (req, res) => {
  const post = await Post.findById(req.params.id);
  if (!post) throw new AppError('Post not found', 404);

  // Check: is the current user the post owner, or an admin?
  const isOwner = post.author.toString() === req.user.id;
  const isAdmin = req.user.role === 'admin';

  if (!isOwner && !isAdmin) {
    throw new AppError('Not authorised to update this post', 403);
  }

  // Proceed with update
  const updates = { title: req.body.title, body: req.body.body };
  const updated = await Post.findByIdAndUpdate(req.params.id, { $set: updates },
    { new: true, runValidators: true });
  res.json({ success: true, data: updated });
});

Testing Auth Routes with Postman

1. POST /api/auth/login  โ†’ get token
   Body: { "email": "user@example.com", "password": "Test@1234" }
   Response: { "token": "eyJ..." }

2. GET /api/auth/me  โ†’ verify token works
   Authorization: Bearer eyJ...
   Expected: 200 { "data": { "_id": "...", "name": "...", "role": "user" } }

3. GET /api/auth/me  โ†’ verify expired/invalid token fails
   Authorization: Bearer invalidtoken
   Expected: 401 { "message": "Invalid token โ€” please log in again" }

4. POST /api/posts  โ†’ verify protect works
   No Authorization header
   Expected: 401 { "message": "No token provided โ€” please log in" }

5. DELETE /api/users/123  โ†’ verify authorize works (as non-admin)
   Authorization: Bearer <user token>
   Expected: 403 { "message": "Role 'user' is not authorised..." }

Common Mistakes

Mistake 1 โ€” Not running protect before authorize

โŒ Wrong โ€” authorize tries to read req.user which is not set:

router.delete('/users/:id', authorize('admin'), deleteUser);
// authorize runs first โ€” req.user is undefined โ†’ crash or wrong error

โœ… Correct โ€” protect always comes before authorize:

router.delete('/users/:id', protect, authorize('admin'), deleteUser); // โœ“

Mistake 2 โ€” Comparing ObjectId and string without .toString()

โŒ Wrong โ€” ObjectId !== string even if they represent the same ID:

if (post.author === req.user.id) { ... }
// post.author is a Mongoose ObjectId, req.user.id is a string โ€” always false!

โœ… Correct โ€” convert to string for comparison:

if (post.author.toString() === req.user.id) { ... } // โœ“

Mistake 3 โ€” Using 401 instead of 403 for authorisation failures

โŒ Wrong โ€” returning 401 when a user is authenticated but lacks permission:

throw new AppError('Not allowed', 401); // 401 = not authenticated

โœ… Correct โ€” use the semantically correct status codes:

// 401 Unauthorized = not authenticated (no token or invalid token)
// 403 Forbidden    = authenticated but not permitted
throw new AppError('Not authorised to access this route', 403); // โœ“

Quick Reference

Task Code
Extract token req.headers.authorization?.split(' ')[1]
Verify and decode const decoded = jwt.verify(token, JWT_SECRET)
Attach user req.user = await User.findById(decoded.id).select('-password')
Protect a route router.get('/me', protect, getMe)
Restrict to roles router.delete('/x', protect, authorize('admin'), handler)
Owner check post.author.toString() === req.user.id
No token โ†’ 401 AppError('No token provided', 401)
Wrong role โ†’ 403 AppError('Not authorised', 403)

🧠 Test Yourself

A user with role ‘user’ sends a DELETE request to /api/users/123 which is protected by protect, authorize('admin'). The user has a valid JWT. What HTTP status code and message does the API return?