Testing API Authorization with a Two-Account Access Matrix
Use synthetic users and an explicit access matrix to uncover object, function, and tenant authorization failures in APIs.
An API can correctly identify a user and still return a record that belongs to somebody else. Authentication establishes who is calling; authorization decides whether that caller may perform this action on this object. A useful API pentest makes those decisions visible.
Model the expected decisions
Imagine a project management service with two organizations. Create synthetic users Alice and Ben in different organizations, plus a manager in Alice's organization. Give each account its own project and invoice.
Build a compact matrix before testing:
| Action | Owner | Same-organization manager | Other-organization user |
|---|---|---|---|
| Read an invoice | Allow | Allow if policy permits | Deny |
| Edit project settings | Allow if role permits | Allow if policy permits | Deny |
| Export organization data | Role-dependent | Role-dependent | Deny |
Have the product owner confirm the intended policy. A tester cannot reliably identify an authorization defect without knowing what access is intended.
Compare controlled requests
Using only approved test objects, replay a successful read request with the other synthetic user's credentials. Keep the object reference unchanged. The server should deny access and return no protected fields. An error status is insufficient if the response body still contains the invoice.
Repeat the comparison for updates, deletion, exports, and background job results when those actions are in scope. An application may protect its detail endpoint while overlooking a bulk operation. Avoid touching real customer objects to establish the same point.
Look beyond object identifiers
Review whether the server derives organization membership and privileges from trusted session information. Client-supplied tenant IDs, role fields, and ownership fields require independent checks. Random identifiers make guessing harder, but they cannot replace authorization.
For each finding, record the two account roles, synthetic object, expected decision, actual response, and minimum reproduction steps. Explain whether the problem crosses an ownership boundary, a role boundary, or a tenant boundary.
Keep the fix testable
Apply policy checks consistently in the service layer or shared authorization mechanism used by every relevant route. Add regression cases for both permitted and forbidden actions. Retesting should cover nearby endpoints that rely on the same decision.
See OWASP's authorization testing guidance and Authorization Cheat Sheet for further guidance.
Reproducible lab: tenant, owner, and role are separate decisions
Revision — 28 October 2026: this section adds a local policy simulation and its verification output. It uses synthetic invoices and makes no network requests. The code was executed with Python 3.9.6; no external packages are required. This is a policy-unit example, not an HTTP endpoint or a real customer engagement.
Suppose the product rule is: an invoice owner may read it, and a manager may read invoices within their own organization. A manager from another organization must still be denied. The permission rule needs to check the organization before applying the role exception.
Save the following complete example as authorization_lab.py, then run python3 authorization_lab.py:
from dataclasses import dataclass
@dataclass(frozen=True)
class Actor:
user_id: int
organization_id: int
role: str
@dataclass(frozen=True)
class Invoice:
owner_id: int
organization_id: int
total: int
def can_read_invoice(actor: Actor, invoice: Invoice) -> bool:
if actor.organization_id != invoice.organization_id:
return False
return actor.user_id == invoice.owner_id or actor.role == "manager"
invoice = Invoice(owner_id=11, organization_id=101, total=250)
cases = [
("owner", Actor(11, 101, "customer"), True),
("same-tenant customer", Actor(12, 101, "customer"), False),
("same-tenant manager", Actor(13, 101, "manager"), True),
("other-tenant manager", Actor(21, 202, "manager"), False),
]
for label, actor, expected in cases:
actual = can_read_invoice(actor, invoice)
assert actual is expected, (label, expected, actual)
print(f"{label}: {'ALLOW' if actual else 'DENY'}")
print("4 policy checks passed")
Observed output:
owner: ALLOW
same-tenant customer: DENY
same-tenant manager: ALLOW
other-tenant manager: DENY
4 policy checks passed
Why the negative cases matter
The same-tenant customer case catches an implementation that equates organization membership with invoice ownership. The other-tenant manager case catches an implementation that checks role == "manager" before restricting the organization. Both failures could pass a test suite containing only owner and manager success cases.
As a local learning exercise, remove the organization check and rerun the file. The assertion for the other-tenant manager should fail. Restore the guard before continuing. This change demonstrates why a particular regression check exists; it does not require accessing somebody else's records.
Connecting the policy to a real API
A correct function is useful only if the route actually calls it. In a real application, the actor must come from a verified session or token, and the invoice's ownership must come from trusted persisted data. Do not accept a caller-supplied organization or role as the authority for the decision.
Add an HTTP-level check using two synthetic accounts: authenticate as the other-organization manager, request the test invoice, and assert both denial and absence of private response fields. Choose 403 or 404 according to the application's disclosure policy. Then verify that the owner can still read the invoice. Repeat for exports and background download links if they use separate handlers.
The local example covers four read-policy decisions. It does not exercise authentication, database scoping, invoice updates, signed URLs, deleted organization memberships, or concurrent changes. Those need integration cases in the application under review.
A practical report entry
Record the intended rule, account role and tenant, synthetic invoice reference, actual status, and the smallest decisive response excerpt. Keep the observed result separate from estimates about how many real invoices might be exposed. A remediation ticket should name the missing policy boundary and the regression cases expected at retest.
Comments 0
Share your questions, experiences, or feedback about this article.
Sign in with Google to leave a comment.
Sign in with Google
No comments yet. Be the first to share your feedback.