Access Control Testing

Access control testing checks that the authorization decision from the lesson on authentication and authorization is enforced on every request: the server allows exactly what the access policy allows, for every role, action and record, and refuses the rest. It is negative testing by design: you ask for what the current user should not get. In an invoicing app, a viewer replays the accountant's "send invoice" request copied from the browser's developer tools and expects 403 with the invoice unchanged.

Access control breaks in three ways: vertically, when a lower role performs a higher role's function; horizontally, when a user reaches another user's record at the same level, called an insecure direct object reference (IDOR) when the record is addressed by an identifier the caller can change; and at function level, when a function is not protected at all. A roles × actions matrix turns the policy into test conditions for all three.

Why it matters

OWASP Top 10:2025 ranks Broken Access Control first (A01); the OWASP API Security Top 10 (2023) opens with Broken Object Level Authorization (API1) and also lists Broken Function Level Authorization (API5). Real users follow the screens built for their role, so testing that follows those screens never sends the requests that expose these defects, and one changed identifier can expose every customer's data.

For a tester these tests are cheap and valuable: two test accounts and a request tool, expected results fixed by the policy so they automate well, and findings that are often rated critical or major because they expose data or actions that belong to someone else.

Vertical, horizontal and function-level failures

Authorization answers one question per request: may this identity perform this action on this resource? The check has two levels. A function-level check asks whether this role may call this function at all (invite a user, export all invoices); an object-level check asks whether this user may act on this particular record (invoice 3107, not 3108). Two failures are named by the direction in which the caller gains access; the third by the check that is missing.

  • Vertical privilege escalation: a lower role uses a higher role's function, for example a viewer sends the owner's POST /api/users. The check is missing or trusts the client, such as a role carried in a cookie or request body. Expected: 403, and nothing happens.
  • Horizontal privilege escalation: the same role, someone else's record. An accountant of Company A changes one number and requests GET /api/invoices/3108, an invoice of Company B. OWASP calls this an insecure direct object reference (IDOR); the API Security Top 10 calls the defect Broken Object Level Authorization (BOLA). Expected: 403, or 404 when the contract hides other customers' records.
  • Missing function-level access control: a function protected only by obscurity. The link is not shown, the endpoint is undocumented, the old /api/v1/export still answers, or the check covers GET but not DELETE on the same URL. Expected: 401 or 403 as the policy says; any success is a defect. A missing function-level check is also the usual cause of vertical escalation; the separate name keeps your attention on functions that no screen shows and no list names.
  • A finer level: a user who may update a record changes a field that is not theirs, such as "role": "owner" in a profile update, which the API Security Top 10 calls Broken Object Property Level Authorization (API3:2023). Keep it on your list; the API security testing lesson goes deeper.

Try it: API Request Builder

From the access policy to a roles × actions matrix

The test basis is the access policy. Few teams write it down, so assemble it from the requirements, the role descriptions, each role's menus and the API contract, and have the product owner confirm it: every cell you cannot fill is a question. Rows are the roles, plus Anonymous and, for the horizontal case, a user of the same role in another team or company. Columns are every action the product offers, taken from every source rather than one role's screens: menus, the API contract, exports, admin pages, and the requests the highest role's session makes.

Fill every cell with the expected result before you send anything: success status and visible effect for an allowed cell, status and "no side effect" for a refused one. Each cell is a test condition: a refused cell whose action belongs to a higher role is a vertical test; an allowed cell that names a record is repeated with another user's record as the horizontal test; every column is also requested directly in every row as the function-level test. The OWASP Authorization Cheat Sheet asks teams to write unit and integration tests for their authorization logic; the matrix is the shared source for those tests and for yours.

Try it: OpenAPI → Test Cases

Worked example: an invoicing app with three roles

An invoicing service at https://invoices.test, in a test environment you have written permission to test, serves many companies. Policy: "A user belongs to one company and sees only its data; a request about another company's invoice returns 404. Viewers read invoices. Accountants also create and send invoices. Owners also export all invoices and invite users. Without a valid session the API returns 401." Test accounts: Maha (viewer), Yousef (accountant) and Rana (owner) in Company A, which owns invoice 3107, and Tariq (accountant) in Company B.

Sign in as Rana, perform each of the five actions once and copy every request it sends from the Network panel as cURL; opening invoice 3107, for example, also loads its PDF preview from GET /api/invoices/3107/pdf. Replay every request in a request tool with Maha's, Yousef's and Tariq's session cookie in turn and once with no cookie, changing nothing else. If a request also carries a token tied to Rana's session, such as an anti-CSRF token, swap it as well, or a refusal may come from that token check rather than from authorization. The run found two defects.

Invoicing app access matrix: of 24 caller-and-action cells only 9 are allowed; the viewer gets 403 for every accountant and owner action, and Company B’s accountant gets 404 for Company A’s invoice 3107.
CallerA1 · view invoice 3107GET /api/invoices/3107A2 · create an invoicePOST /api/invoicesA3 · send invoice 3107POST /api/invoices/3107/sendA4 · export all invoicesGET /api/export/invoices.csvA5 · invite a userPOST /api/users
Anonymousno session401authentication401authentication401authentication401authentication401authentication
Maha · viewerCompany A200allowed403vertical403vertical403vertical403vertical
Yousef · accountantCompany A200allowed201allowed200allowed403vertical403vertical
Rana · ownerCompany A200allowed201allowed200allowed200allowed201allowed
Tariq · accountantCompany B, on Company A’s invoice404horizontal (IDOR)no Company A record involved404horizontal (IDOR)403vertical403vertical
  • Allowed by the policy, with the expected success code
  • Vertical test: 403 for a function above the caller’s role
  • Horizontal (IDOR) test: 404 for another company’s invoice, which the contract hides
  • Authentication: 401 without a valid session

Expected results written from the policy before any request is sent: viewers read invoices; accountants also create and send them; owners also export all invoices and invite users; another company’s invoice returns 404.

  • Maha's replay of POST /api/invoices/3107/send returned 200 and the customer received the invoice: a vertical escalation; the check existed in the menu only. Severity: critical.
  • Tariq's GET /api/invoices/3107 correctly returned 404, but the PDF preview request GET /api/invoices/3107/pdf returned the PDF: a horizontal escalation through a second route to the same record. Severity: critical.
  • Every other cell matched the matrix. Confirm each fix by re-running the whole column, not only the failed cell.
Text version
Expected result per cell, written from the policy before any request is sent

Caller                 A1 view 3107  A2 create  A3 send 3107  A4 export  A5 invite
Anonymous              401           401        401           401        401
Maha, viewer A         200           403        403           403        403
Yousef, accountant A   200           201        200           403        403
Rana, owner A          200           201        200           200        201
Tariq, accountant B    404           -          404           403        403

401 = authentication (no session)    403 = vertical: a function above the role
404 = horizontal: Company B's user on Company A's invoice (the contract hides it)

Try it: cURL Parser Try it: API Authentication Tester Try it: API Response Comparator

Where unprotected functions and identifiers hide

A matrix is only as complete as its columns, and function-level defects live in the columns nobody listed. Before you sign off, look for what the obvious list misses.

  • Other routes to the same record: a PDF or print view, a search endpoint with a company filter, a bulk export, a sharing link.
  • Other methods and versions of the same URL: a check on GET but not on PUT or DELETE, or an old API version still deployed.
  • Values the client sends about itself: companyId, role or isOwner in a body, query string, cookie or hidden field. The server must take identity and company from the session; change these values and expect them to be ignored or refused.
  • Files and random identifiers: an uploaded PDF served from a storage URL without a session check, or a UUID that is hard to guess. Both hide a record; neither authorises the reader. Copy a real identifier from the other test account and expect a refusal.

Try it: REST API Tester

Common mistakes

  • Testing each role only through its own screens and never sending the requests the screens do not offer.
  • Running most tests as an owner or admin, who passes every check and therefore tests none.
  • Testing a record's main route and skipping its PDF, export, search and sharing routes.
  • Accepting any refusal: a redirect to the login page whose body still carries the data, or a 403 after which the action happened anyway. Read the body and check the data afterwards.
  • Treating hidden links, random file names or UUIDs as access control.
  • Testing production or a system you have no written permission for, or pasting live cookies and tokens into bug reports instead of masking them.

The QA perspective

Ask for the access policy during refinement, as soon as a feature has more than one role or more than one owner: which roles exist, who owns each kind of record, what each role may do, and which status a refusal returns, including for another company's record. Write the matrix as the test basis, let the product owner confirm every cell, and order test accounts for every role plus one user of the same role in another team or company.

Record each run as the matrix with expected and actual result per cell; in a bug report name the cell, both identities, the masked credentials, the request, the response and the side effect. Because the policy fixes the expected results, turn the matrix into API tests that run on every release. Leave exploitation to security specialists, and test nothing outside the test environment without written permission that names the scope.

Practice exercise

Build the access matrix for a course platform

Your task

A course platform has three roles. Learners see only their own submissions and certificates. Instructors see the learner list, see and grade the submissions, and see the certificates of the courses they teach. Admins do everything and alone refund payments. The API returns 401 without a valid session, 403 for a function the role may not use, and 404 for a record of another learner or another course. You have written permission to test the test environment.

Build the matrix of callers against the five actions in the material, predict the status of every cell, and label each refused cell as authentication, vertical or horizontal. Then add three tests from the section on hidden functions.

Material
Setup: submission 4410 = learner Lina, course QA-101 (instructor Hani)
       certificate 7702 = learner Omar, course DB-201 (instructor Dana)
       Lina and Omar are learners; Hani and Dana are instructors; Sami is an admin

#  Action                     Request
A1 view submission 4410       GET  /api/submissions/4410
A2 grade submission 4410      POST /api/submissions/4410/grade
A3 download certificate 7702  GET  /api/certificates/7702
A4 list learners of QA-101    GET  /api/courses/QA-101/learners
A5 refund a payment           POST /api/payments/88/refund
Steps
  1. List the callers: Anonymous, every role, and a second user of the same role for the horizontal case.
  2. List the actions from every source, including those a learner never sees.
  3. Fill each cell from the policy before you look at the system.
  4. Label each refused cell: authentication (401), vertical (403) or horizontal (404).
  5. Add tests for other routes, methods and client-supplied values.
Show the model answer
Model answer

Anonymous: 401 in every column (authentication). Sami, admin: allowed in every column, 200 for the reads and 200 or 201 for the grade and the refund as the contract defines.

Lina, learner and owner of 4410: A1 200; A2 403 (vertical: learners never grade); A3 404 (horizontal: Omar's certificate); A4 403 and A5 403 (vertical).

Omar, learner: A1 404 (horizontal, the classic IDOR cell); A2 403 (vertical); A3 200; A4 403 and A5 403 (vertical).

Hani, instructor of QA-101: A1 200; A2 allowed (200 or 201 as the contract defines), then confirm the grade is stored; A3 404 (horizontal: a certificate of Dana's course); A4 200; A5 403 (vertical: refunds are admin-only).

Dana, instructor of DB-201: A1 and A2 404 (horizontal: a submission of another course, grade unchanged); A3 200; A4 404 (horizontal: another instructor's course); A5 403 (vertical).

Hidden-function tests, for example: Lina requests Omar's certificate as a file, GET /api/certificates/7702/pdf, expecting 404 and no file; Lina sends DELETE /api/submissions/4410, expecting 403 or 405 as the contract defines and no change; Dana grades 4410 with "courseId": "DB-201" in the body, expecting 404 with the grade unchanged.

Frequently asked questions

Is IDOR the same as horizontal privilege escalation?

IDOR is the typical form of horizontal escalation: a record is addressed by an identifier the caller can change, and the server does not check ownership. Horizontal escalation also happens through search filters, exports and sharing links that return other users' records without any identifier being edited. The API Security Top 10 groups all of these under Broken Object Level Authorization (BOLA).

How many test accounts do I need?

At least one per role, plus one more user of the same role in another team or company for the horizontal row, all in a test environment and never real users' accounts. For multi-tenant products, where one system serves many companies, you need two companies with their own records, so that every object-bound action has a record that belongs to someone else.

Do random UUIDs make IDOR tests unnecessary?

No. A UUID is hard to guess, but identifiers leak through exports, emails, logs and other users' pages, and the server must refuse the request whoever presents it. Copy a real identifier from the other test account and expect the same refusal a sequential id would get.

References

  • OWASP Web Security Testing Guide (WSTG)OWASP FoundationThe reference methodology for testing web applications and APIs for security weaknesses, test by test.
  • OWASP Top 10:2025OWASP Foundation · 2025The ten most critical web application security risks, with how to test for and prevent each.
  • OWASP Cheat Sheet Series: AuthorizationOWASP FoundationAccess control done right: least privilege, deny by default, check permissions on every request, prefer attribute- or relationship-based models, and test the authorization logic.
  • OWASP API Security Top 10 (2023)OWASP Foundation · 2023The ten most common API weaknesses: broken object-level authorization, broken authentication, unrestricted resource consumption and more.

Practise with Mutqan tools

Compose and send HTTP requests (method, URL, headers, auth, body) and inspect the response — a lightweight Postman in your browser.

API Testing

Test Bearer, Basic, API-key and custom-header authentication against an endpoint and compare authorised vs unauthorised responses.

API Testing

Test REST endpoints with full control over method, headers and JSON body, then assert on status, headers and JSON paths.

API Testing

Compare two API responses (e.g. staging vs production) and list every structural and value difference.

API Testing

Generate a structured test-case matrix (positive, negative, auth, boundary) from an OpenAPI / Swagger specification.

Automation

Parse a cURL command into method, URL, headers and body, then convert it to fetch, axios, Python or a structured request.

Web

Go deeper

Your progress on this topic

Part of the QA Roadmap