
TL;DR. Changing one ID and checking for
200 OKgives you a spot check. Tahr treats authorization as a matrix: caller identity, target owner, tenant boundary, operation, object ID, expected policy, observed response, and proof. Suspicious cells start as candidates. They reach the report only after the system can show the request, the response, the identity context, and the reason access should have been denied.
Most IDOR testing is too small. A scanner finds /api/orders/{id}, swaps 123 for 124, gets a 200, and calls it a broken object-level authorization issue. Sometimes that turns out to be real. Often it collapses during review.
Sometimes that response is just the caller's own object because the server ignored the supplied ID. Sometimes it is an empty envelope with success: false, an error body behind a friendly 200, same-tenant access the product deliberately allows, or cached data. It can also be a real bug. Without a second identity and a known owner, the test has no way to prove which case it found.
Authorization bugs live in relationships. Who is calling? Who owns the object? Are they in the same tenant? Is the operation read-only or state-changing? Should this role be able to perform it? What evidence shows the server made the wrong decision?
The useful unit of work is the matrix.
The spot-check failure mode
The naive test is attractive because it is cheap. Pick an endpoint, replace an ID, replay the request, inspect the status code. That catches obvious failures and leaves a pile of ambiguous signals behind.
REST makes this worse because status codes lie by convention as much as by mistake. Some APIs return 200 with an authorization error in the body. Some return 404 to hide the existence of another user's object. Some return 403 for real denials but 200 for validation errors. Treat the status code as a clue, then read the body.
GraphQL makes it even easier to fool yourself. The HTTP status is usually 200 whether the operation succeeded or failed. The authorization decision lives inside data, errors, nested fields, aliases, fragments, or mutation results. If the test only reads the transport status, it misses the actual policy decision.
Coverage is the deeper problem. Testing one caller against one object says almost nothing about the authorization model. A useful test compares roles, owners, tenants, authenticated users, and unauthenticated access across the operation catalog.
If the product has admin, manager, member, and viewer roles, the useful question is broader: which roles can reach which owners' objects across which tenant boundaries?
| Caller | Target owner | Boundary | Operation | Expected | Observed |
|---|---|---|---|---|---|
| member A | member A | own object | GET /orders/{id} |
allow | allow |
| member A | member B | same tenant | GET /orders/{id} |
deny | allow |
| member A | member C | cross tenant | GET /orders/{id} |
deny | allow |
| unauthenticated | member A | no session | GET /orders/{id} |
deny | deny |
The second and third rows carry the signal. The first row proves the operation works for a legitimate owner. The fourth row proves the endpoint requires a session. Without those surrounding cells, the suspicious 200 is just a loose fact.
Authorization is a matrix
Tahr's authorization agent starts from a stricter question: which identity can do what, and where does the policy fail?
That question gives the test its matrix shape. One axis is the operation catalog: REST paths from OpenAPI, GraphQL queries and mutations from schema or introspection, methods, parameters, request bodies, and resource hints. Another axis is identity: configured users, their roles, their sessions, their tenant metadata, and an unauthenticated caller. A third axis is ownership: IDs harvested for each identity, grouped by resource type and parameter name.
Conceptually, each cell needs more than a status:
{
"caller": "member_a",
"operation": "GET /api/orders/{id}",
"object_owner": "member_b",
"tenant_boundary": "same_tenant",
"expected": "deny",
"observed": "success_with_data",
"evidence": "request and response captured"
}
The actual artifact is more compact because downstream systems consume it, but the important part stays the same: each cell records context. Caller, owner, boundary, and evidence all travel with the result.
This changes what the agent optimizes for. Instead of collecting isolated wins, it fills a coverage map and explains every gap. If a role cannot authenticate, the output says so. If an owner has no usable IDs, that pair gets a skip reason. If a token is bound to browser state, requests run through the browser-bound session path instead of pretending a bearer token exists.
Building the test surface
The first job is to build the surface area without inventing endpoints.
For REST, the OpenAPI parser produces an operation catalog with methods, paths, parameters, request body shapes, and resource hints. For GraphQL, the schema path builds a catalog of operations and the dependency chains needed to reach useful objects. Authorization testing loses discipline when it turns into endpoint discovery halfway through the run. Endpoints outside the spec or schema belong to a different agent. The authorization agent stays inside the declared surface.
Then the system resolves identities. Configured users become role keys. Sessions are generated through role-specific login scripts. The unauthenticated caller is added explicitly. If multiple tenants are present, the test enables cross-tenant mode so identities from one tenant can request objects owned by another.
ID pools are the next hard part. A placeholder like 00000000-0000-0000-0000-000000000000 is useful for checking error behavior. It will not prove IDOR. Real authorization proof needs real object IDs tied to known owners. Tahr groups harvested IDs by role, parameter name, and resource type so the caller can be changed independently from the owner.
That separation is the point. When member A requests member B's object, the request uses member A's session and member B's object pool. If those two facts are not separate, the test can accidentally validate only the happy path.
Batch execution beats hand-written curls
An LLM can write curl commands. The harder work is making sure every role hits the same operation set, with comparable substitution rules, consistent headers, retry behavior, response classification, and audit capture.
Tahr uses deterministic batch execution for that reason. Each configured role gets a batch run across the operation catalog. The batch runner records the status, body size, body hash, response category, confidence, and the curl shape used for the request. It also writes a wire-level audit artifact for each matrix cell: the sanitized request and response pair stored next to the authorization output.
That audit trail matters when a row looks suspicious. A reviewer should not have to rerun the whole assessment to understand whether a 200 was real data, an error envelope, a redirect, or a role-mismatch artifact. The evidence is already attached to the cell.
The runner also handles the practical mess. Large catalogs may need partial batches and resume behavior. A role may need a refreshed token. Some applications bind API access to browser state, so the request has to go through a browser-context helper rather than a portable authorization header. These details decide whether the test survives real customer applications.
Cross-identity sweeps
Per-role batches answer one question: what does this identity see when it uses its own object pool?
IDOR needs a second question: what happens when the caller and owner split apart?
The cross-identity sweep runs every caller against every other owner's object pool. For each pair, the same operation catalog is replayed with the caller's auth context and the owner's IDs. That fills same-tenant and cross-tenant cells in the matrix.
At that point, authorization bugs become easier to reason about. A lower-privilege identity reaching an admin operation is vertical privilege escalation. A peer reaching another peer's object is horizontal IDOR. A caller from tenant A reaching tenant B's resource is cross-tenant IDOR. An unauthenticated caller reaching protected data is auth bypass.
The categories are familiar. The surrounding cells make the classification defensible. The system knows the owner, the caller, the operation, the expected denial, and the observed behavior. That reduces the number of "maybe IDOR" findings that collapse during review.
Matrix cells are not findings
This boundary matters most.
A red-looking matrix cell starts as a candidate. Promotion requires proof.
For read-style authorization failures, proof means the unauthorized caller received meaningful protected data. The response body has to support the claim. The identity and object context have to be trusted. The expected denial has to be clear.
For state-changing operations, a successful status is even weaker. POST, PUT, PATCH, and DELETE need read-back, persistence, disappearance, or equivalent response-body proof. A mutation that returns 204 may have changed nothing. A 200 from a profile update may simply echo the submitted payload before dropping it. Filing that as mass assignment without proof would be noise.
Server errors get handled carefully too. A 500 can be useful diagnostic evidence. It becomes security-relevant only if it leaks protected data or proves unauthorized reachability with enough context. Otherwise it stays internal.
That boundary keeps the matrix honest. The matrix is for coverage and analysis. The report is for claims with evidence.
GraphQL changes the shape
GraphQL authorization testing follows the same principle, though the cells look different.
The status code is usually useless, so the result has to read the body. Did data come back? Which field? Did the server return an auth error, a validation error, or partial data? Did a wide selection expose fields that a narrow selection hid? Did aliases or batched operations change enforcement? Did a nested traversal reach data that the top-level operation denied?
GraphQL also has dependency chains. A mutation may need an ID created by another operation. A query may expose useful object IDs only after a viewer/me query establishes the identity. Missing a prerequisite does not prove authorization is correct. The matrix has to distinguish auth denial from "we did not build the right object graph yet."
The high-level approach stays the same across REST and GraphQL: build the catalog, resolve identities, harvest owner-specific IDs, execute comparable cells, classify the body, then decide whether there is a violation.
Where it breaks
The matrix depends on the identities and objects available to it.
If a target provides only one usable account, cross-role and cross-owner testing is thin. If the app has roles but no real object diversity, the matrix can show coverage gaps but cannot prove much. If ID harvesting fails, some caller-owner pairs stay empty. That should be reported as incomplete coverage, not filled with synthetic guesses.
Policy can also be genuinely ambiguous. Same-tenant peer access might be allowed in a collaboration product and forbidden in a finance product. An admin-like role might be expected to read across tenants in an internal console but blocked in a customer-facing API. The matrix can show what happened. It cannot infer business policy from nothing.
Non-portable sessions are slower. When the application binds API access to browser state, the test has to run through the browser context. That path costs more time and matches the application more closely. Treating the session as portable when it is not would be faster and wrong.
The last failure mode is over-promotion. A matrix makes suspicious patterns visible, and that visibility can tempt a system to turn every anomaly into a report item. The promotion boundary prevents that. Request/response evidence, expected-denial context, and state-change proof are the price of entry.
Why this matters
Authorization is where many serious application bugs live, but it is also where shallow automation produces the most noise. The difference between "changed an ID and saw 200" and "proved member A can read member B's cross-tenant invoice" is the difference between a hunch and a vulnerability.
Tahr makes the hunch measurable. Build the matrix. Fill it with comparable executions. Keep the audit trail. Treat suspicious cells as candidates. Promote the cases that can survive evidence review.
Authorization testing stays hard. The work just becomes harder to fake.
Next
The matrix is one part of the larger system. Previous posts covered why Tahr separates exploration from verification and how the watchdog keeps agents from drifting mid-run. Authorization testing adds a different lesson: some vulnerability classes need structure more than creativity. The structure makes coverage gaps, policy assumptions, and proof requirements visible.