List every admin or role-limited operation, sign in as a lower role, and call each one with several HTTP methods. A 403 or 401 is the right answer. A 200 means the function is open to a user who should not have it. Also look for endpoints where only some of them check roles.
What you are testing
This is broken function level authorization, OWASP API5:2023. It differs from object level problems. Here the question is not "can I read someone else's order?" but "can I run a function reserved for another role?" Typical examples are deleting another user, changing a role, exporting all records, or approving a refund.
The procedure
Following the OWASP Web Security Testing Guide:
- Identify function level endpoints. Use the spec, captured traffic or an intercepting proxy. Look for administrative functions, role-based operations and critical user functions. Focus on differences between roles.
- Test role-based access. Sign in as a low-privilege user, for example a regular user or guest, and send requests to endpoints meant for higher roles.
- Vary HTTP methods. Send GET to read data meant for admins, POST, PUT or PATCH to modify sensitive data such as a user's role, and DELETE to remove sensitive resources. Compare outcomes.
- Test GraphQL separately. Alter queries and mutations to see whether a lower role can invoke restricted ones.
- Evaluate the results. Signs of a flaw are a low-privilege user running a high-privilege function, restricted functions returning 200 instead of an error, and inconsistent enforcement, where some endpoints check roles and others do not.
A results table that works
| Operation | Admin | Manager | Regular user | Anonymous |
|---|---|---|---|---|
| GET users list | 200 | 200 | 403 expected | 401 expected |
| PATCH user role | 200 | 403 expected | 403 expected | 401 expected |
| DELETE user | 200 | 403 expected | 403 expected | 401 expected |
| POST export all | 200 | 200 | 403 expected | 401 expected |
Fill in the actual codes and highlight any cell that differs from the expectation. The pattern across rows matters. If DELETE is protected but PATCH is not, the roles check was added per handler and one was missed.
How admin routes get exposed
- The admin screen is hidden in the UI, but the route is not protected.
- The check exists on the web route but not on a mobile or partner route that reaches the same function.
- An older API version lacks a check added later. OWASP's inventory guidance says to keep protection on every exposed version.
- A different HTTP method reaches the same handler without the check.
Fixing it well
Put the role check in a central place, such as middleware or a policy layer, and deny by default so a new route is closed until someone opens it. Then add a test for each privileged operation that asserts a lower role gets a 403.
Where this fits
Run this check by hand once per release train and automate the matrix as a regression suite. A function level test is easier to automate than most logic tests, because the expected answer is a status code.
Worked example: an HR portal with a hidden export
Take an HR portal API with these routes, taken from the spec and from captured traffic:
| Route | Intended for |
|---|---|
| GET /api/me | Any employee |
| GET /api/employees | Managers and HR |
| PATCH /api/employees/ID/role | HR admins |
| DELETE /api/employees/ID | HR admins |
| POST /api/reports/payroll-export | HR admins |
| GET /api/v1/employees | Older version, unknown owner |
The tester has four accounts: an employee, a manager, an HR admin and an anonymous session.
Step 1: identify privileged functions. From the table, the list of role-limited functions is employees list, role change, delete, payroll export, and the v1 list.
Step 2: low role, documented methods. As the employee, the tester calls each privileged route with its documented method. The employees list returns 403, role change returns 403, delete returns 403. The payroll export returns 200 and a file. First finding: the export route has no role check. It was never linked in the UI for employees, so nobody noticed.
Step 3: other methods. The tester tries GET, PUT, PATCH and DELETE against the employee route as the employee. PUT on /api/employees/ID is not documented, but it returns 200 and changes the employee's own record, including a department field. That might be acceptable for a user's own record, but when the tester sends PUT for a colleague's ID, it also succeeds. That is both a function level and an object level problem, found through a method the spec did not list.
Step 4: old version. GET /api/v1/employees as the employee returns the whole list. The newer version checks roles, the old one never got the check. Third finding.
Step 5: results grid.
| Operation | Employee | Manager | HR admin | Anonymous |
|---|---|---|---|---|
| GET employees v2 | 403 | 200 | 200 | 401 |
| GET employees v1 | 200 (flaw) | 200 | 200 | 401 |
| PATCH role | 403 | 403 | 200 | 401 |
| DELETE employee | 403 | 403 | 200 | 401 |
| PUT employee, other user | 200 (flaw) | 200 (flaw) | 200 | 401 |
| POST payroll export | 200 (flaw) | 200 (flaw) | 200 | 401 |
Three rows with flaws, all of them visible as a break in the pattern of the column.
What the team does next
Fix the three routes, then fix the cause. The common thread is that role checks are written per handler. The team moves to a central rule: routes are denied unless a policy names the roles that may call them. They also retire v1 or backport the check, following the inventory advice. Finally, they keep the grid as an automated test: each cell is a request with an expected status. A new route without a row in the grid fails the build.
A caution on interpretation
A 200 is not always a flaw. A manager reading the employee list is intended. That is why the expected column matters. Build the expected grid from the role descriptions first, then run the requests, then compare. Doing it the other way round invites rationalizing every result you see.
Try it yourself
Open the demo and look at how an example scan groups findings by endpoint. Picture the three flawed rows from the results grid above appearing in a list like this.
Click through it at your own pace, or open it full screen.
Quick check
Does a hidden menu item count as access control?
No. If the endpoint answers a lower role, the function is exposed regardless of what the UI shows.
Should the lower role get 401 or 403?
The guide treats either as correct enforcement. Use 401 for missing or invalid credentials and 403 for a valid user who lacks permission, and keep it consistent.
How is this different from the object level test?
Object level tests ask whether you can reach another user's record. Function level tests ask whether you can run an operation reserved for another role. Real APIs often have both flaws on the same route.
Sources
- OWASP WSTG: API broken function level authorization
- OWASP API Security Top 10 2023
- OWASP API9:2023 Improper Inventory Management
Last reviewed: 19 October 2026.