# Multi-tenant authorization testing: prove the boundary, then retest it

For a small B2B SaaS team shipping workspaces or permissions, multi-tenant authorization testing needs more than a tenant ID. Build a matrix of actors, objects, roles, and operations; prove both intended access and denied access; then repeat the checks against the deployed fix.

By Gabe | 2026-10-07

Use two fictional workspaces, Atlas and Birch, and a private report belonging to Atlas. This is a **synthetic teaching scenario**, not a discovered vulnerability or a completed assessment. A member, viewer, and administrator have different permissions. An administrator in Birch has no special authority over Atlas just because both accounts use the same role name.

Before opening a testing tool, write the policy in ordinary language. For this example: Atlas members may read their own private reports; Atlas administrators may read reports within Atlas; viewers may not read these private reports. No requester from Birch may read the Atlas report. This is a chosen example policy, not a universal rule for SaaS products.

## Tenant ID, object ownership, and role answer different questions

A tenant ID identifies the workspace being selected. Object ownership identifies the relationship between a resource and its tenant, creator, assignee, or sharing policy. A role defines allowed operations within its scope. None of those facts alone proves that the current user may perform the requested operation on this object.

| Input | What it tells you | What it doesn't establish |
| --- | --- | --- |
| Selected tenant | Which workspace the request concerns | That the requester currently belongs to it |
| Object ID | Which report or file is requested | That it belongs to the selected tenant or may be accessed |
| Ownership relationship | Which principal or tenant controls the resource | That every owner-related operation is permitted |
| Role | A permission class within a scope | Cross-tenant privileges or access to every object |

OWASP's [Multi-Tenant Application Security Cheat Sheet, section 1](https://cheatsheetseries.owasp.org/cheatsheets/Multi_Tenant_Security_Cheat_Sheet.html) says client-supplied tenant identifiers are selectors only. Bind the selected tenant to a server-verified identity and current membership or explicit service authorization. A hidden field, URL segment, or header cannot grant membership.

The backend must also establish that the report is accessible under that tenant's policy and that the action is permitted. OWASP [API1:2023, Broken Object Level Authorization](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/) requires checks for the requested action on the requested object. It explicitly says comparing a session user ID with an object identifier is insufficient for the general problem.

An unpredictable UUID can reduce enumeration. It isn't authorization. The test doesn't need to guess identifiers: use known objects created in the authorized synthetic fixtures. That produces a clearer comparison and avoids involving unrelated customer data.

## Write the matrix before collecting requests

Build rows from the business rule, not just the routes you happen to notice. Start with actor, tenant, role, resource relationship, and operation. Include any documented sharing or platform-support exception as a separate explicit path.

[WSTG 4.2, WSTG-ATHZ-04](https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/05-Authorization_Testing/04-Testing_for_Insecure_Direct_Object_References) recommends at least two users, often more, with different objects and relevant privileges. [WSTG-ATHZ-02](https://github.com/OWASP/wstg/blob/v4.2/document/4-Web_Application_Security_Testing/05-Authorization_Testing/02-Testing_for_Bypassing_Authorization_Schema.md) covers horizontal access between identities and vertical access between privilege levels. For a multi-tenant application, include both same-tenant role differences and cross-tenant relationships.

For the private-report read policy, use these controls:

| Requester and relationship | Expected decision | Purpose |
| --- | --- | --- |
| Atlas member, own report | Allow | Positive control for ordinary intended access |
| Atlas member, another member's report | Deny | Same-tenant object restriction |
| Atlas administrator, another member's report | Allow | Positive control for the documented admin exception |
| Atlas viewer, either owner | Deny | Role restriction |
| Birch administrator, Atlas report | Deny | Tenant boundary despite elevated local role |
| Birch member, Atlas report | Deny | Tenant boundary for ordinary user |

Use separate sessions and clearly labeled fixtures. Confirm that the protected object exists with an authorized control. If every operation fails because authentication expired, nothing about tenant isolation has been established. Preserve that run as blocked and repair access setup before evaluating the policy.

## Read, write, and business actions need separate controls

A passed read check cannot establish a passed update, deletion, export, or invitation check. Those operations can use different handlers, policies, and background jobs. Don't compress them into a single “report access” result.

OWASP [ASVS 5.0.0 V8](https://github.com/OWASP/ASVS/blob/v5.0.0/5.0/en/0x17-V8-Authorization.md) separates function permission (`v5.0.0-8.2.1`), object permission (`v5.0.0-8.2.2`), and field permission (`v5.0.0-8.2.3`). Requirement `v5.0.0-8.4.1` addresses cross-tenant controls. Requirements `v5.0.0-8.1.1` and `v5.0.0-8.1.2` call for documented rules; mapping a test to these IDs does not establish full ASVS verification.

Extend the read matrix with application-specific rows:

- **Write:** an authorized member changes an allowed report title; the same member cannot change protected ownership, tenant association, or restricted fields.
- **Action:** an allowed administrator starts an export; a viewer cannot start it even if they can see some report metadata.
- **Delivery:** the export contains only permitted objects and fields; a different tenant cannot fetch its result.
- **Lifecycle:** removing a membership stops the access required by policy while another authorized member retains functionality.
- **Bulk:** each selected object is authorized; an allowed item must not smuggle a forbidden one into the same request.

These distinguish [API3:2023, property-level authorization](https://owasp.org/API-Security/editions/2023/en/0xa3-broken-object-property-level-authorization/) from [API5:2023, function-level authorization](https://owasp.org/API-Security/editions/2023/en/0xa5-broken-function-level-authorization/). A user may be allowed to edit an object but forbidden to set its billing status. A user may also be forbidden to invoke an administrative function at all.

For read denials, check that no protected content, fields, or download capability was returned. For writes, compare state before and after. For actions, inspect queues, notifications, exports, and downstream changes through authorized evidence access. A denial message isn't sufficient if the operation already happened.

## Carry the boundary into storage, caches, and workers

Tenant isolation must survive each path that handles tenant-owned data. The visible HTTP route is only one point of enforcement. OWASP's [multi-tenant guidance, sections 2 and 4–6](https://cheatsheetseries.owasp.org/cheatsheets/Multi_Tenant_Security_Cheat_Sheet.html) covers database access, protected cached values, asynchronous work, and storage signing.

<figure style="--text:var(--ink);--line:var(--border);--accent:var(--primary);margin:2rem 0;padding:1rem;border:1px solid var(--line);border-radius:12px;color:var(--text)">
  <div role="img" aria-label="Export boundary sequence: verified requester and tenant membership; authorized operation and report set; worker re-establishes authorized context; download authorizes the exact export object. Each stage can deny independently." style="display:grid;gap:1rem">
    <div style="padding:1rem;border-left:4px solid var(--accent)"><strong>1. Requester</strong><br><span style="color:var(--muted)">Verified identity and selected tenant membership.</span></div>
    <div style="padding:1rem;border-left:4px solid var(--accent)"><strong>2. Export request</strong><br><span style="color:var(--muted)">Permitted action, report set, and returned fields.</span></div>
    <div style="padding:1rem;border-left:4px solid var(--accent)"><strong>3. Worker</strong><br><span style="color:var(--muted)">Trusted job context; permission checked for delayed work.</span></div>
    <div style="padding:1rem;border-left:4px solid var(--accent)"><strong>4. Download</strong><br><span style="color:var(--muted)">Authorize the exact result before serving or signing it.</span></div>
  </div>
  <figcaption style="color:var(--muted);margin-top:1rem">Synthetic export flow. A check at creation time doesn't automatically protect later worker execution or result delivery.</figcaption>
</figure>

If PostgreSQL row-level security is part of the design, test through the actual request role. [PostgreSQL 18 documentation, section 5.9](https://www.postgresql.org/docs/18/ddl-rowsecurity.html) states that superusers and `BYPASSRLS` roles always bypass row security; table owners normally bypass it unless forced. `FORCE ROW LEVEL SECURITY` doesn't constrain the first two cases. A policy file alone can't prove the deployed connection uses a constrained role.

Exercise reused connections with both tenants. Verify that tenant context is re-established and cannot leak from the previous request. Inventory tenant-owned tables so new ones cannot silently escape the policy. These are architecture-specific checks; an application using separate databases needs evidence for its credential and routing boundaries instead.

A tenant-scoped cache key prevents one class of mixing but doesn't grant permission to read the cached value. Authorize before returning protected cached content. Likewise, a tenant-prefixed storage key isn't an access policy. Check permission for the exact object and operation before generating a signed URL, and document how its lifetime relates to membership removal.

For delayed exports, authenticate the producer path, preserve trusted context, and re-establish authorization at the consumer. Test removal between request and execution according to the declared policy. Include permitted service identities without assuming every internal worker has universal access.

## Use the local lab to distinguish agreement from security

The authorization lab attached to this article is a local simulation. It models `report-104`, which belongs to Atlas, and compares the chosen read policy with a deliberately insecure ID-only lookup. Its implementation declares the insecure result allowed for every selection. It doesn't query your app, send a scan, or enforce a production boundary.

Start with an Atlas member reading their own report: both modeled decisions allow. That agreement doesn't make the lookup secure. Change only the owner to another member: the policy denies while the insecure lookup still allows. Next choose an Atlas administrator: the policy allows either owner, because the administrator exception is scoped to Atlas.

Finally choose Birch with the administrator role. The policy denies, and the insecure lookup allows. This separates tenant membership from role privilege. An Atlas viewer is also denied by the policy, even for the owner setting labeled “Requester.” Ownership doesn't override this scenario's private-report role rule.

Those are **modeled policy outcomes, not observations from a real application's access controls**. Local browser tests exercise all 12 tenant/role/owner combinations and check that interaction sends no network requests. The lab covers one read decision. It doesn't cover session validation, writes, export actions, sharing exceptions, database policies, or concurrency. Use it to understand which policy input changed, then build the broader matrix for your actual product.

## Record the failure precisely and retest after deployment

An evidence record should let another authorized engineer distinguish a missing check from a fixture mistake. Use a stable finding reference and capture:

| Field | What to retain privately |
| --- | --- |
| Target | Environment, deployed revision, policy/configuration version, timestamp |
| Actor | Synthetic user, authenticated session reference, tenant, role, current membership |
| Resource | Object reference, verified tenant association, owner or sharing relationship |
| Operation | Read, specific field update, delete, export, download, or other named action |
| Controls | Intended successful case and closely matched forbidden case |
| Outcome | Sanitized sensitive output, before/after state, or downstream event evidence |
| Limits | Untested paths, inaccessible state, timeouts, and unresolved interpretation |

“The handler may omit a tenant predicate” is a hypothesis. A reproducible prohibited read or mutation confirms a narrower runtime effect. Don't infer compromise of every tenant from one synthetic object. Investigate breadth within scope and preserve uncertainty where evidence is missing. WSTG 4.2's [Reporting chapter, section 3](https://github.com/OWASP/wstg/blob/v4.2/document/5-Reporting/README.md) recommends replication, impact, remediation detail, and masked sensitive data.

Fix the enforcing boundary, then retest against the deployed revision. Repeat the original case and relevant variants: another object, sibling operation, cached response, or delayed job. Positive controls must still work. Record verified closure for the specific finding, leaving excluded workflows visible. A code merge or reviewer approval is not the retest result; the [coding-agent security guide](/blog/coding-agent-security) explains that distinction for generated repairs.

### Authorization release acceptance checklist

- [ ] Every included operation has a written policy, including sharing and administrative exceptions.
- [ ] Synthetic fixtures cover two tenants, relevant roles, own and other objects.
- [ ] Authorized reads, writes, and actions succeed as separate positive controls.
- [ ] Forbidden reads disclose nothing protected; forbidden writes and actions produce no prohibited effects.
- [ ] Removal, cache, storage signing, workers, and effective database roles are checked where relevant.
- [ ] Failures include reproducible sanitized evidence and a named fix owner.
- [ ] Deployed retests preserve legitimate behavior and identify the tested revision.
- [ ] Blocked and untested matrix cells remain visible rather than counted as passes.

## Decide what the evidence supports before rollout

The matrix can support a bounded release decision, not a claim that the entire application is secure. Our [website pentesting guide](/blog/website-pentesting-guide) explains scope and report acceptance; the [statistics reference](/blog/penetration-testing-statistics) and [AI pentesting leaderboard reference](/blog/ai-pentesting-leaderboard) explain why external numbers can't fill untested cells.

Pensec, which we build, currently accepts early-access readiness requests. Submitting the form does not start automated testing. Ownership, authorized targets, accounts, actual delivery capacity, and retest terms need separate confirmation. You can prepare this matrix internally and use it to brief an independent assessor without exposing real customer data.

## Sources and review notes

Primary guidance retrieved **2026-10-07**. The scenario, matrix, and acceptance checklist are editorial synthesis, not empirical findings. Revisit them when roles, sharing rules, storage paths, or job behavior change.

- OWASP, **WSTG 4.2** (2020): [WSTG-ATHZ-04, IDOR](https://wstg.owasp.org/v4.2/4-Web_Application_Security_Testing/05-Authorization_Testing/04-Testing_for_Insecure_Direct_Object_References), How to Test; [WSTG-ATHZ-02, authorization bypass](https://github.com/OWASP/wstg/blob/v4.2/document/4-Web_Application_Security_Testing/05-Authorization_Testing/02-Testing_for_Bypassing_Authorization_Schema.md), horizontal and vertical tests; [Reporting, section 3](https://github.com/OWASP/wstg/blob/v4.2/document/5-Reporting/README.md). Retrieved 2026-10-07.
- OWASP, [**ASVS 5.0.0 V8 Authorization**](https://github.com/OWASP/ASVS/blob/v5.0.0/5.0/en/0x17-V8-Authorization.md), 2025. Requirements `v5.0.0-8.1.1`, `v5.0.0-8.1.2`, `v5.0.0-8.2.1`, `v5.0.0-8.2.2`, `v5.0.0-8.2.3`, `v5.0.0-8.4.1`. Retrieved 2026-10-07.
- OWASP, **API Security Top 10, 2023**: [API1, object-level authorization](https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/); [API3, property-level authorization](https://owasp.org/API-Security/editions/2023/en/0xa3-broken-object-property-level-authorization/); [API5, function-level authorization](https://owasp.org/API-Security/editions/2023/en/0xa5-broken-function-level-authorization/). Is the API Vulnerable? and How To Prevent sections. Retrieved 2026-10-07.
- OWASP, [**Multi-Tenant Application Security Cheat Sheet**](https://cheatsheetseries.owasp.org/cheatsheets/Multi_Tenant_Security_Cheat_Sheet.html), living guidance; no fixed publication year assigned. Sections 1–6, including Verify Tenant Isolation. Retrieved 2026-10-07.
- PostgreSQL Global Development Group, [**PostgreSQL 18, section 5.9: Row Security Policies**](https://www.postgresql.org/docs/18/ddl-rowsecurity.html), version 18 documentation (major release 2025; living corrections). Retrieved 2026-10-07.
- Pensec, **local authorization lab**, 2026 teaching implementation: `src/lib/authorization-lab.ts` and `src/components/blog/AuthorizationLab.astro`, inspected 2026-10-07. Expected behavior read from implementation; runtime not verified for this article. [Current early-access site](https://pensec.app), status checked against README Request handling and Service readiness on the same date.

The interactive authorization matrix is a fictional, local-only simulation available on the HTML article. It does not send requests or run scans.

