NodeJSProtecting Routes

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

JS
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)
Apply middleware at the router level so every future route in that group is protected by default
Route-level middleware (`app.get('/foo', authenticate, handler)`) is easy to forget on a new route. Router-level middleware (`router.use(authenticate)`) runs for **all** routes registered on that router, present and future. Structure your app so protected groups are their own routers, and public routes are the exception that's explicitly carved out — not the other way around. The default should be "protected."
Carving out public routes within a protected router

JS
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)
Public routes and protected routes should be on SEPARATE routers — don't mix them
Adding public routes to a protected router by placing them before `router.use(authenticate)` works — but it's fragile. Someone adds a new "public" route in the wrong place and it silently inherits authentication. Keep public endpoints (login, register, password reset, OAuth callbacks) on their own router with **no** auth middleware; the protected router *only* contains routes that require authentication. The separation is architectural documentation: "this whole file needs a valid user."
Per-resource authorization pattern

JS
// 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

Text
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]
Each gate has one responsibility: authenticate, then authorize, then act
Read the chain left-to-right as a series of questions: *Is this request valid JSON? Is this a known user? Does this user have the required role? Does this user own this resource?* Each middleware answers one question and either passes control forward or terminates with the appropriate error. Mixing concerns (e.g. an ownership check inside authentication middleware) makes the chain hard to test, reuse, and reason about.
Testing protected routes

JS
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)
  })
})
Write auth tests for the three cases: unauthenticated, wrong role, correct role
Every protected endpoint should have at minimum three integration tests: **no credentials → 401**, **wrong role → 403**, and **correct credentials → success**. These tests catch the most common auth bug — a route that silently omits the middleware and is world-accessible. They're cheap to write and the fastest way to enforce that protection is actually applied. Use a helper that signs a token with any role to keep test setup minimal.
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 authorizationauthenticate always 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.

Next
Now that auth is solid, learn to handle errors gracefully: [Error Handling in Node.js](/nodejs/error-handling-intro).