~/learn / authorization / testing-access-control

Testing Access Control

A methodical, two-account approach to finding authorization bugs, with an access matrix, mass-assignment and GraphQL specifics, the right tooling, and how to report what you find.

intermediateupdated

Access control bugs are the most common high-impact findings in web and API testing, and finding them is more method than cleverness. Scanners are poor at this because they do not know which data belongs to whom, which rows are sensitive, or what the application is for. You do, and that human context is the whole edge.

Test only applications you own, licensed practice platforms, or bug bounty programs whose scope includes the target. Use only test accounts you created, and never access or modify real users’ data beyond the minimum needed to prove a bug.

Set up two of everything

You need at least two accounts at each privilege level:

Account Purpose
User A Owns test objects: orders, documents, messages
User B Same role as A, different data. Tests horizontal access
Admin or higher role Shows what privileged functions exist. Tests vertical access
No account Tests whether endpoints require login at all

Create recognizable test data in each account, such as a document titled user-a-private-test, so a leak is unmistakable in a response and you never confuse it for your own.

Build the access matrix

Browse the application as the most privileged account with your proxy recording every request, then build a table of what each role should be able to do:

Endpoint Anonymous User A on own data User A on B’s data Admin
GET /api/orders/{id} Deny Allow Deny Allow
PATCH /api/users/{id} Deny Allow, limited fields Deny Allow
DELETE /api/users/{id} Deny Deny Deny Allow
GET /api/admin/reports Deny Deny Deny Allow

Every “Deny” cell is a concrete test case. This table is the deliverable that turns a vague “check authorization” into a finite, checkable list, and it is what separates methodical testing from poking around.

Run the tests

Horizontal (object level, IDOR/BOLA). Take each request User A made for their own objects, swap in User B’s session, and replay. Then invert it: as User A, request B’s object ids. A 200 with B’s data is the bug.

Vertical (function level, BFLA). Replay every admin request with User A’s session, then with no session at all. Pay special attention to endpoints the UI hides from non-admins; the server is the only thing that matters.

Property level (mass assignment). Many frameworks bind request fields straight onto model attributes. On create and update requests, add fields the UI never sends: role, isAdmin, is_verified, ownerId, price, balance, account_id. If the server binds them, you escalate by simply naming the attribute. The root cause is a binder that trusts the whole request body; the fix is an explicit allowlist of writable fields (strong parameters in Rails, DTOs elsewhere) rather than a blocklist of dangerous ones.

Indirect and alternate references. Protection is often applied to the obvious route and forgotten on its neighbors. GET /orders/1002 may be locked while GET /orders/1002/invoice.pdf, GET /export?order=1002, the GraphQL field order(id: 1002), or an old /v1/ endpoint is not. Enumerate every path that reaches the same object.

API-specific angles

  • GraphQL. A single endpoint hides many operations. Introspection often lists every type and field; a field-level authorization gap lets you request user { email, passwordResetToken } through a nested relation even when the top-level query looks safe. Test each field, and watch for batching: sending an array of queries, or aliasing the same mutation many times, to bypass rate limits or race a check.
  • Rate-limited actions. Object-level checks and anti-automation checks are different controls; a missing one does not imply the other. Verify both.

Multi-step and stateful authorization

Some authorization only makes sense across a sequence. A workflow may check your role at step one and assume it at step three; request step three directly and the assumption fails. A resource created in a draft state may be editable by anyone until “submitted,” so changing its state out of order bypasses the gate. Map the state machine (see business logic) and test each transition as a separate authorization decision, because the server frequently treats them as one.

Let tools do the repetition

  • Autorize (Burp extension) replays every request you make with a second user’s session and flags responses that look identical to the privileged one. Browse as admin with Autorize holding User A’s cookie, and candidate bugs light up as you go.
  • Burp’s compare-site-maps diffs what two sessions can reach.
  • Beware false confidence from unguessable ids. UUIDs make blind guessing hard but do nothing for authorization: the ids leak through other endpoints, emails, exports, and referers, and once known they work exactly the same. Unguessable is not authorized.

Reading the responses

A 403 or 404 is the healthy outcome. Be suspicious of:

  • 200 with another user’s data: the clear bug.
  • 200 with an empty body: the action may have run anyway. Verify the underlying data changed.
  • Different errors for “exists but forbidden” versus “does not exist”: this leaks which ids are real, an enumeration primitive even when the object is protected.

Field notes: the two-account IDOR method

The fastest way to find broken object-level authorization: hold two accounts, low and lower. Browse as low-privilege account A, capture a request that reads or changes one of A’s objects (GET /api/orders/1001), then replay it while authenticated as account B. If B gets A’s data, that is an IDOR. Burp Autorize and AuthMatrix automate this by replaying every request through a second session’s cookies and flagging the ones that should have failed but did not.

When ids are not sequential, you still attack them:

  • GUIDs leak in other responses (a list endpoint, an email, a Location header), so harvest them rather than guess.
  • Hashids / short hashes are often reversible or brute-forceable; a predictable scheme is not access control.
  • Encoded ids (base64, ?id=MTAwMQ==) are just id 1001 wearing a costume.

Field notes: the moves scanners miss

  • Mass assignment / parameter mining. Add fields the UI never sends: "role":"admin", "isVerified":true, "userId":<someone else>, "price":0. Diff a verbose response to learn the model’s real field names, then set them on update. Burp Param Miner guesses hidden parameters and headers.
  • HTTP verb and method tampering. A 403 on GET /admin/user/5 sometimes becomes 200 on POST, PUT, or HEAD, or with X-HTTP-Method-Override. Controls are frequently bound to one verb.
  • Forced browsing to function-level endpoints (BFLA). A regular user POSTing to /admin/* or calling an admin GraphQL mutation often succeeds because the function checked authentication, not authorization. Pull endpoint names from the JS bundle and the API docs.
  • GraphQL field-level authz. The query is authorized but a nested field is not, so request the sensitive field through a relationship (me { friends { email paymentMethods } }). Alias batching also bypasses per-request rate limits and brute-forces OTPs by packing many attempts in one request.
  • Multi-step and header-based bypasses. Replay step 3 of a flow without steps 1 and 2; try X-Original-URL, X-Rewrite-URL, and X-Forwarded-For: 127.0.0.1 to reach internal-only routes a proxy was supposed to guard.

Keep an access matrix (roles down the side, endpoints across the top) and fill every cell, because the empty ones are where the bugs hide. Test only accounts and systems in your authorized scope.

Reporting

A strong access-control report contains:

  1. The two accounts used and their roles, confirming no real user was involved.
  2. The exact request that should have been denied and the response proving it was not.
  3. Impact in plain language: “Any logged-in user can read any other user’s invoices, including names and addresses.”
  4. The fix, almost always an ownership or role check enforced in the data query itself (WHERE id = ? AND owner_id = ?), not bolted on afterward.

Practice on PortSwigger’s access control labs, OWASP crAPI, and OWASP Juice Shop, all built for exactly this.