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 an HTTP method must never 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?
Refunds, exports, maintenance actions. These are the operations where a missing check costs the most.
Deny by default
Keep the policy in one place, where you can read it. Then check coverage route by route. Every handler declares what it requires, and anything that declares nothing gets 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 the refund button from regular users. That changes nothing on the server. Anyone can send the request by hand. Hiding a button is a UX decision, not access control.
So we send the request by hand:
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
Policies tend to cover the route someone was thinking about when they wrote them. Attackers try everything else.
A new POST route can escape a policy written for the older DELETE on the same path. So test sibling endpoints, alternate methods, batch actions, and older API versions, always with a low-privilege identity.
Route aliases and versions need the same rule. /api/v1/admin/refunds and /admin/refunds must behave the same. A batch endpoint that wraps refunds needs the rule too.
Watch for the inverse bug as well. A policy applied to everything under /admin/* misses an administrative action mounted somewhere else, like POST /invoices/:id/force-close. Route prefixes are a convention. They are not a security boundary.
Prove coverage instead of assuming it
Central policy helps. A coverage test proves it. Loop over every admin route with a low-privilege token and demand a 403:
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. Much cheaper than learning about it from a bug bounty report.
Try this on your own API: build a role-by-method matrix for the refund, export, and maintenance routes. Run every cell that should be denied with a low-privilege token. Save the evidence that nothing changed in the database and that each denial left an audit entry.
Lesson completed