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
- Extract the token from the
Authorizationheader or cookie - Verify the token signature and check expiration
- Decode the payload (userId, role, etc.)
- Attach the decoded data to
req.user - Call
next()to proceed -- or return401on 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:
| Error | Code | Client Action |
|---|---|---|
| No token provided | NO_TOKEN | Redirect to login |
| Token expired | TOKEN_EXPIRED | Try refresh token |
| Invalid/tampered token | INVALID_TOKEN | Redirect to login |
| User deleted | USER_NOT_FOUND | Redirect to login |
| Password changed | PASSWORD_CHANGED | Redirect 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
- Auth middleware extracts the token, verifies it, and sets
req.user-- or returns401. - Use
authorize(...roles)as a middleware factory that returns a function checkingreq.user.role. - Apply
authenticateglobally (for protected route groups) or per-route (for mixed public/private). - Role-based (admin, moderator, user) is simpler; permission-based (user:write, post:delete) is more granular.
- Ownership checks ensure users can only modify their own resources unless they are admins.
- Always run
authenticatebeforeauthorize-- you must know who before checking what.
Explain-It Challenge
Explain without notes:
- Why is
authorize('admin')a factory function that returns middleware, rather than middleware itself? - A route has
authenticatebut noauthorize. Can any logged-in user access it? Is that always acceptable? - 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 ->