Authorization
Enforce function-level authorization
Protect administrative, export, billing, and maintenance operations with explicit server-side permissions on every route and method.
Changing a path or HTTP method must not turn an ordinary user into an administrator. Object-level checks ask “whose invoice is this?”. Function-level authorization asks a different question: may this role call this operation at all?
Deny by default
Centralize policy where it stays visible, but verify coverage route by route. Every handler declares what it requires, and anything undeclared is denied:
app.post('/admin/refunds', requirePermission('refunds:create'), issueRefund)
app.get('/admin/exports', requirePermission('exports:read'), listExports)
// a route without requirePermission gets rejected by the
// outer middleware instead of running unprotected
The UI hides /admin/refunds, but an ordinary user can still send the request. Hiding a button is a UX decision, not an access control.
curl -i -X POST https://api.example.com/admin/refunds \
-H "Authorization: Bearer $REGULAR_USER_TOKEN" \
-d '{"invoiceId":"inv_9f3c2a","amount":4900}'
# HTTP/1.1 403 Forbidden
Methods and versions escape policies
A new POST route can also escape a policy that only covered the older DELETE method. Test sibling endpoints, alternate methods, batch actions, and older API versions with a low-privilege identity.
Include route aliases and versioned endpoints in the matrix. /api/v1/admin/refunds and /admin/refunds need the same rule, and a batch endpoint that wraps refunds needs it too.
Watch for the inverse bug as well: a policy applied to everything under /admin/* misses an administrative action mounted elsewhere, like POST /invoices/:id/force-close. Route prefixes are a convention, not a security boundary.
Prove coverage instead of assuming it
Central policy helps, but a route-level coverage test proves each handler actually invokes the decision before starting work:
for (const route of adminRoutes) {
const res = await request(app)[route.method](route.path)
.set('Authorization', `Bearer ${lowPrivilegeToken}`)
assert.equal(res.status, 403, `${route.method} ${route.path} is unprotected`)
}
This loop fails the build the day someone adds an admin route and forgets the permission check. That is a much cheaper discovery than a bug bounty report.
Build a role-by-method matrix for refund, export, and maintenance routes. Run every denied cell with a low-privilege token and save evidence that no side effect or audit gap remains.
Lesson completed