Safeguard
Security Guides•

How do you test whether a normal user can call admin API functions?

List admin and role-limited operations, call them as a lower role with every HTTP method, and judge the status codes. A five-step function access test.

Safeguard Security Team
7 min read

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:

  1. 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.
  2. 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.
  3. 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.
  4. Test GraphQL separately. Alter queries and mutations to see whether a lower role can invoke restricted ones.
  5. 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

OperationAdminManagerRegular userAnonymous
GET users list200200403 expected401 expected
PATCH user role200403 expected403 expected401 expected
DELETE user200403 expected403 expected401 expected
POST export all200200403 expected401 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:

RouteIntended for
GET /api/meAny employee
GET /api/employeesManagers and HR
PATCH /api/employees/ID/roleHR admins
DELETE /api/employees/IDHR admins
POST /api/reports/payroll-exportHR admins
GET /api/v1/employeesOlder 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.

OperationEmployeeManagerHR adminAnonymous
GET employees v2403200200401
GET employees v1200 (flaw)200200401
PATCH role403403200401
DELETE employee403403200401
PUT employee, other user200 (flaw)200 (flaw)200401
POST payroll export200 (flaw)200 (flaw)200401

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

Last reviewed: 19 October 2026.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.