Episode 3 — NodeJS MongoDB Backend Architecture / 3.14 — Authentication and Authorization

3.14.e — Auth Middleware

In one sentence: Express authentication middleware extracts and verifies tokens on every request, attaches the user to req.user, and authorization middleware enforces role-based access control (RBAC) by checking if that user has the required role or permission.

Navigation: <- 3.14.d JWT Authentication | 3.14.f -- Passport.js ->


1. What auth middleware does

Auth middleware sits between the incoming request and the route handler, acting as a gatekeeper:

Request → [parse body] → [auth middleware] → [authz middleware] → [route handler] → Response
                              │                    │
                              │ WHO are you?        │ WHAT can you do?
                              │ verify token         │ check role/permission
                              │ attach req.user      │ allow or 403
                              ▼                    ▼
                         401 if invalid        403 if insufficient

2. Creating authentication middleware

Step-by-step: what the middleware does

  1. Extract the token from the Authorization header or cookie
  2. Verify the token signature and check expiration
  3. Decode the payload (userId, role, etc.)
  4. Attach the decoded data to req.user
  5. Call next() to proceed -- or return 401 on failure

Complete implementation

// middleware/authenticate.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');

async function authenticate(req, res, next) {
  try {
    // --- Step 1: Extract token ---
    let token;

    // Option A: Authorization header (Bearer token)
    const authHeader = req.headers.authorization;
    if (authHeader && authHeader.startsWith('Bearer ')) {
      token = authHeader.split(' ')[1];
    }

    // Option B: Cookie (fallback)
    if (!token && req.cookies?.accessToken) {
      token = req.cookies.accessToken;
    }

    // No token found
    if (!token) {
      return res.status(401).json({
        success: false,
        error: 'Authentication required. No token provided.',
      });
    }

    // --- Step 2: Verify token ---
    const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET);

    // --- Step 3: Optionally fetch full user from database ---
    // Lightweight: trust the token payload (faster, no DB call)
    // req.user = decoded;

    // Full check: verify user still exists and is active (safer)
    const user = await User.findById(decoded.userId).select('-password');
    if (!user) {
      return res.status(401).json({
        success: false,
        error: 'User no longer exists.',
      });
    }

    // Optional: check if user changed password after token was issued
    if (user.passwordChangedAt) {
      const changedTimestamp = Math.floor(user.passwordChangedAt.getTime() / 1000);
      if (decoded.iat < changedTimestamp) {
        return res.status(401).json({
          success: false,
          error: 'Password recently changed. Please log in again.',
        });
      }
    }

    // --- Step 4: Attach user to request ---
    req.user = user;
    next();
  } catch (err) {
    // --- Step 5: Handle specific errors ---
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({
        success: false,
        error: 'Token expired.',
        code: 'TOKEN_EXPIRED',
      });
    }
    if (err.name === 'JsonWebTokenError') {
      return res.status(401).json({
        success: false,
        error: 'Invalid token.',
      });
    }
    return res.status(500).json({
      success: false,
      error: 'Authentication error.',
    });
  }
}

module.exports = authenticate;

3. Handling token expiration gracefully

Different errors require different client-side actions:

ErrorCodeClient Action
No token providedNO_TOKENRedirect to login
Token expiredTOKEN_EXPIREDTry refresh token
Invalid/tampered tokenINVALID_TOKENRedirect to login
User deletedUSER_NOT_FOUNDRedirect to login
Password changedPASSWORD_CHANGEDRedirect to login
// Client-side interceptor (axios example)
axios.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 &&
        error.response?.data?.code === 'TOKEN_EXPIRED' &&
        !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const { data } = await axios.post('/api/auth/refresh');
        // Store new access token
        setAccessToken(data.accessToken);
        // Retry original request
        originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
        return axios(originalRequest);
      } catch (refreshError) {
        // Refresh also failed -- redirect to login
        window.location.href = '/login';
      }
    }
    return Promise.reject(error);
  }
);

4. Protecting routes

Method 1: Apply to specific routes

const authenticate = require('../middleware/authenticate');

// Only this route is protected
router.get('/profile', authenticate, (req, res) => {
  res.json({ user: req.user });
});

// This route is public
router.get('/posts', (req, res) => {
  res.json({ posts: [] });
});

Method 2: Apply to all routes in a group

const express = require('express');
const authenticate = require('../middleware/authenticate');

const app = express();

// Public routes
app.use('/api/auth', authRoutes);        // login, register, refresh
app.use('/api/public', publicRoutes);    // public content

// Everything below this line requires authentication
app.use('/api', authenticate);
app.use('/api/users', userRoutes);       // protected
app.use('/api/orders', orderRoutes);     // protected
app.use('/api/admin', adminRoutes);      // protected (+ further authz)

Method 3: Router-level middleware

const router = express.Router();

// All routes in this router require authentication
router.use(authenticate);

router.get('/dashboard', getDashboard);
router.get('/settings', getSettings);
router.put('/settings', updateSettings);

module.exports = router;

5. Role-Based Access Control (RBAC)

The concept

RBAC assigns roles to users, and each role has certain permissions. Instead of checking individual permissions for every action, you check the user's role.

HIERARCHY (typical):
  guest → user → moderator → admin → superadmin
  (less)                              (more)

User model with roles

const userSchema = new mongoose.Schema({
  name:     { type: String, required: true },
  email:    { type: String, required: true, unique: true },
  password: { type: String, required: true, select: false },
  role: {
    type: String,
    enum: ['user', 'moderator', 'admin', 'superadmin'],
    default: 'user',
  },
  permissions: [{                        // optional: fine-grained
    type: String,
    enum: ['user:read', 'user:write', 'user:delete',
           'post:read', 'post:write', 'post:delete',
           'admin:access'],
  }],
});

6. Authorization middleware factory

Role-based: authorize(...roles)

// middleware/authorize.js

function authorize(...allowedRoles) {
  return (req, res, next) => {
    // authenticate middleware must run first (sets req.user)
    if (!req.user) {
      return res.status(401).json({
        success: false,
        error: 'Authentication required before authorization.',
      });
    }

    if (!allowedRoles.includes(req.user.role)) {
      return res.status(403).json({
        success: false,
        error: `Forbidden. Required role: ${allowedRoles.join(' or ')}. Your role: ${req.user.role}.`,
      });
    }

    next();
  };
}

module.exports = authorize;

Usage:

const authenticate = require('../middleware/authenticate');
const authorize = require('../middleware/authorize');

// Only admins can manage users
router.get('/admin/users',
  authenticate,
  authorize('admin', 'superadmin'),
  getAllUsers
);

// Moderators and admins can delete posts
router.delete('/posts/:id',
  authenticate,
  authorize('admin', 'moderator'),
  deletePost
);

// Any authenticated user can view their profile
router.get('/profile',
  authenticate,
  getProfile
);

7. Permission-based authorization

For more granular control, check specific permissions instead of roles.

// middleware/requirePermission.js

function requirePermission(...requiredPermissions) {
  return (req, res, next) => {
    if (!req.user) {
      return res.status(401).json({ error: 'Authentication required' });
    }

    const userPermissions = req.user.permissions || [];
    const hasPermission = requiredPermissions.every(
      (perm) => userPermissions.includes(perm)
    );

    if (!hasPermission) {
      return res.status(403).json({
        error: 'Insufficient permissions',
        required: requiredPermissions,
        yours: userPermissions,
      });
    }

    next();
  };
}

module.exports = requirePermission;

Usage:

// Need 'user:delete' permission specifically
router.delete('/users/:id',
  authenticate,
  requirePermission('user:delete'),
  deleteUser
);

// Need both 'post:write' and 'post:delete'
router.put('/posts/:id/moderate',
  authenticate,
  requirePermission('post:write', 'post:delete'),
  moderatePost
);

8. Role-permission mapping

A common pattern: define permissions per role, then resolve at login.

// config/roles.js

const ROLES = {
  user: {
    permissions: ['post:read', 'post:write', 'user:read'],
    description: 'Regular user',
  },
  moderator: {
    permissions: ['post:read', 'post:write', 'post:delete', 'user:read'],
    description: 'Content moderator',
  },
  admin: {
    permissions: [
      'post:read', 'post:write', 'post:delete',
      'user:read', 'user:write', 'user:delete',
      'admin:access',
    ],
    description: 'Full admin',
  },
  superadmin: {
    permissions: ['*'], // wildcard: all permissions
    description: 'Super administrator',
  },
};

function getRolePermissions(role) {
  return ROLES[role]?.permissions || [];
}

function hasPermission(userRole, requiredPermission) {
  const perms = getRolePermissions(userRole);
  return perms.includes('*') || perms.includes(requiredPermission);
}

module.exports = { ROLES, getRolePermissions, hasPermission };

9. Resource ownership check

Sometimes authorization is about ownership, not just roles:

// middleware/ownerOrAdmin.js

function ownerOrAdmin(resourceUserIdField = 'userId') {
  return (req, res, next) => {
    const resourceOwnerId = req.params[resourceUserIdField] || req.params.id;
    const requesterId = req.user._id.toString();
    const isOwner = resourceOwnerId === requesterId;
    const isAdmin = ['admin', 'superadmin'].includes(req.user.role);

    if (!isOwner && !isAdmin) {
      return res.status(403).json({
        error: 'You can only access your own resources.',
      });
    }

    next();
  };
}

module.exports = ownerOrAdmin;

Usage:

// User can update their own profile; admins can update anyone's
router.put('/users/:id',
  authenticate,
  ownerOrAdmin(),
  updateUser
);

10. Complete RBAC implementation example

// --- app.js (tying it all together) ---
const express = require('express');
const cookieParser = require('cookie-parser');
const authenticate = require('./middleware/authenticate');
const authorize = require('./middleware/authorize');

const app = express();
app.use(express.json());
app.use(cookieParser());

// ── PUBLIC ROUTES ──────────────────────────────
app.use('/api/auth', require('./routes/auth'));

// ── AUTHENTICATED ROUTES ───────────────────────
app.use('/api', authenticate); // all routes below need a valid token

// Any authenticated user
app.get('/api/profile', (req, res) => {
  res.json({ user: req.user });
});

// Any authenticated user -- own posts
app.get('/api/my-posts', require('./routes/posts').getUserPosts);

// ── MODERATOR+ ROUTES ──────────────────────────
app.delete('/api/posts/:id',
  authorize('moderator', 'admin', 'superadmin'),
  require('./routes/posts').deletePost
);

// ── ADMIN ROUTES ───────────────────────────────
app.use('/api/admin', authorize('admin', 'superadmin'));
app.get('/api/admin/users', require('./routes/admin').getAllUsers);
app.put('/api/admin/users/:id/role', require('./routes/admin').changeRole);
app.delete('/api/admin/users/:id', require('./routes/admin').deleteUser);

// ── SUPERADMIN ONLY ────────────────────────────
app.get('/api/admin/audit-log',
  authorize('superadmin'),
  require('./routes/admin').getAuditLog
);

// ── ERROR HANDLER ──────────────────────────────
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal server error' });
});

module.exports = app;

11. Testing auth middleware

// __tests__/authenticate.test.js
const jwt = require('jsonwebtoken');
const authenticate = require('../middleware/authenticate');

// Mock request, response, next
function mockReqResNext(headers = {}, cookies = {}) {
  const req = { headers, cookies };
  const res = {
    status: jest.fn().mockReturnThis(),
    json: jest.fn(),
  };
  const next = jest.fn();
  return { req, res, next };
}

describe('authenticate middleware', () => {
  const SECRET = 'test-secret';
  process.env.JWT_ACCESS_SECRET = SECRET;

  test('returns 401 when no token is provided', async () => {
    const { req, res, next } = mockReqResNext();
    await authenticate(req, res, next);

    expect(res.status).toHaveBeenCalledWith(401);
    expect(next).not.toHaveBeenCalled();
  });

  test('returns 401 for expired token', async () => {
    const token = jwt.sign({ userId: '123' }, SECRET, { expiresIn: '0s' });
    const { req, res, next } = mockReqResNext({
      authorization: `Bearer ${token}`,
    });

    // Wait for token to expire
    await new Promise((r) => setTimeout(r, 1000));
    await authenticate(req, res, next);

    expect(res.status).toHaveBeenCalledWith(401);
    expect(res.json).toHaveBeenCalledWith(
      expect.objectContaining({ code: 'TOKEN_EXPIRED' })
    );
  });

  test('attaches user to req on valid token', async () => {
    const token = jwt.sign({ userId: '123', role: 'admin' }, SECRET, {
      expiresIn: '1h',
    });
    const { req, res, next } = mockReqResNext({
      authorization: `Bearer ${token}`,
    });

    // Note: in a real test, mock User.findById
    await authenticate(req, res, next);

    expect(req.user).toBeDefined();
    expect(next).toHaveBeenCalled();
  });
});

12. Key takeaways

  1. Auth middleware extracts the token, verifies it, and sets req.user -- or returns 401.
  2. Use authorize(...roles) as a middleware factory that returns a function checking req.user.role.
  3. Apply authenticate globally (for protected route groups) or per-route (for mixed public/private).
  4. Role-based (admin, moderator, user) is simpler; permission-based (user:write, post:delete) is more granular.
  5. Ownership checks ensure users can only modify their own resources unless they are admins.
  6. Always run authenticate before authorize -- you must know who before checking what.

Explain-It Challenge

Explain without notes:

  1. Why is authorize('admin') a factory function that returns middleware, rather than middleware itself?
  2. A route has authenticate but no authorize. Can any logged-in user access it? Is that always acceptable?
  3. How would you handle a scenario where an admin should be able to edit any user's profile, but regular users should only edit their own?

Navigation: <- 3.14.d JWT Authentication | 3.14.f -- Passport.js ->