Protecting Routes
You have authentication middleware and role checks — now you need to apply them consistently across your Express router without forgetting a route. This page shows the patterns for protecting entire routers at once, selectively opening public endpoints, nesting authorization per resource, and structuring the middleware chain so authentication and authorization stay cleanly separated and impossible to accidentally skip.
Protect a whole router, not individual routes
import { Router } from 'express'
import { authenticate } from './middleware/authenticate.js'
import { requireRole } from './middleware/requireRole.js'
// All routes in this router require authentication:
const apiRouter = Router()
apiRouter.use(authenticate) // runs before EVERY route in this router
apiRouter.get('/me', (req, res) => res.json(req.user))
apiRouter.get('/posts', listPostsHandler)
apiRouter.post('/posts', createPostHandler)
// A nested admin sub-router — requires authentication AND admin role:
const adminRouter = Router()
adminRouter.use(authenticate)
adminRouter.use(requireRole('admin')) // all admin routes doubly guarded
adminRouter.delete('/users/:id', deleteUserHandler)
adminRouter.get('/users', listUsersHandler)
app.use('/api', apiRouter)
app.use('/admin', adminRouter)Carving out public routes within a protected router
const authRouter = Router()
// These routes are public — they come BEFORE the authenticate middleware:
authRouter.post('/login', loginHandler)
authRouter.post('/register', registerHandler)
authRouter.post('/forgot-password', forgotPasswordHandler)
const protectedRouter = Router()
protectedRouter.use(authenticate) // everything below this line requires auth
protectedRouter.get('/me', meHandler)
protectedRouter.put('/me', updateMeHandler)
app.use('/auth', authRouter)
app.use('/api', protectedRouter)Per-resource authorization pattern
// Authenticate once at the router level; authorize per-action or per-resource:
const postsRouter = Router()
postsRouter.use(authenticate) // must be logged in
postsRouter.get('/', listPostsHandler) // any authenticated user
postsRouter.post('/', requireRole('editor', 'admin'), createPostHandler)
postsRouter.get('/:id', getPostHandler)
// Ownership check inside the handler:
postsRouter.put('/:id', async (req, res) => {
const post = await db.posts.findById(req.params.id)
if (!post) return res.status(404).json({ error: 'Not found' })
if (post.authorId !== req.user.id && req.user.role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' })
}
/* ...update... */
})
postsRouter.delete('/:id', requireRole('admin'), deletePostHandler)The full middleware chain, visualized
Request → [global middleware: body-parser, cors, helmet]
→ [router.use(authenticate)] ← 401 if token/session invalid
→ [route middleware: requireRole('admin')] ← 403 if wrong role
→ [ownership check in handler] ← 403 if not owner
→ [handler logic]Testing protected routes
import request from 'supertest'
describe('DELETE /admin/users/:id', () => {
it('returns 401 without a token', async () => {
await request(app).delete('/admin/users/1').expect(401)
})
it('returns 403 for a non-admin token', async () => {
const token = signToken({ sub: 2, role: 'user' })
await request(app)
.delete('/admin/users/1')
.set('Authorization', `Bearer ${token}`)
.expect(403)
})
it('succeeds for an admin token', async () => {
const token = signToken({ sub: 99, role: 'admin' })
await request(app)
.delete('/admin/users/1')
.set('Authorization', `Bearer ${token}`)
.expect(200)
})
})Checklist
Default protected — new routes inherit auth by being added to a protected router.
Separate routers for public vs protected — no mixed auth levels on one router.
Authentication before authorization —
authenticatealways runs first.401 for missing/invalid identity; 403 for insufficient role — never mix them.
Test every protected route for unauthenticated and wrong-role cases.
Own the authorization logic server-side — no client-only role gates.