ASVS 5.0 V8.2.1 (Level 1) requires that access to application functions is restricted to consumers with explicit permissions. If a caller has not been granted the right to run a function, the function must refuse, no matter how the request arrived.
Why it matters
Function level failures are the classic hidden admin URL problem: an interface hides a button, but the endpoint behind it answers anyone who calls it. Attackers do not use your interface. They read the JavaScript, guess paths, replay requests from lower privileged accounts, and change methods from GET to POST.
"Explicit permissions" means deny by default. A function is closed until a permission opens it, rather than open until someone remembers to lock it.
How to implement it
Enforce at the server, in a layer that every request passes through, and make the permission requirement visible on each route.
Express example with a deny by default router:
const router = express.Router();
router.use(requireSession); // authentication first
function allow(permission) {
return (req, res, next) => {
if (req.user.permissions.includes(permission)) return next();
return res.sendStatus(403);
};
}
router.get("/reports", allow("reports:read"), listReports);
router.post("/reports/:id/export", allow("reports:export"), exportReport);
router.delete("/users/:id", allow("users:delete"), deleteUser);
Spring Security:
http.authorizeHttpRequests(a -> a
.requestMatchers("/admin/**").hasAuthority("admin:access")
.requestMatchers(HttpMethod.DELETE, "/users/**").hasAuthority("users:delete")
.anyRequest().authenticated()); // nothing is public unless listed above
Principles to follow:
- Authenticate, then authorize. Do not mix up a missing login (401) with a missing permission (403).
- Grant permissions, not role names, in code. Roles map to permissions in one place.
- Apply the check on every method and every transport: REST, GraphQL resolvers, websocket messages, background job triggers, internal admin endpoints.
- Fail closed. If the permission lookup errors, deny.
- Keep a route inventory and fail the build if a handler has no declared permission.
Do not rely on obscurity, client side checks, or a reverse proxy rule that someone can bypass by hitting the service directly.
How to test it
Build a route by role matrix and run it automatically.
- Export all routes from the framework or the API specification.
- For each route, call it as anonymous, as the lowest role, and as each higher role.
- Compare the status codes with the documented expectation (see V8.1.1).
Look especially for:
- Admin routes that return 200 or 500 for a normal user. A 500 can still mean the function ran.
- Method switching: a route that blocks POST but allows PUT or PATCH.
- Hidden paths found in the front end bundle, source maps or the API description.
- Alternate prefixes such as a v1 route that skipped the new check.
A dynamic scanner that logs in as several users automates much of this. The web app scan demo shows how access control findings appear in a report:
Click through it at your own pace, or open it full screen.
Common mistakes
- Hiding the menu item and calling it security.
- Checking permissions in some controllers but not in a shared base class.
- Role checks sprinkled through the code with string comparisons, drifting apart over time.
- Permitting by default, then blocking a list of known bad paths.
- Forgetting new routes added by a framework feature or a plugin.
- Trusting a role claim sent by the client in a header or token the server does not verify.
Try it yourself
Create two test accounts, one ordinary and one admin. Log in as the admin, copy the requests for five admin actions as curl commands, then replay them with the ordinary account's token. Anything that is not a 403 deserves a fix and a regression test.
Quick check
Is a 404 acceptable instead of a 403?
Often yes, to avoid revealing that a function exists. What matters is that the function does not run.
Where should the check live?
On the server, close to the function, in shared middleware or policy code, not in the user interface.