DEV Community

Jason Miller
Jason Miller

Posted on Originally published at axeploit.com

Prove Tenant Isolation on Every Deploy: Mint as Tenant A, Replay as Tenant B

Your UUIDs killed the classic IDOR trick, and most testing advice hasn't caught up. You can't increment 10001 into 10002 anymore, so stop enumerating. Make the API mint the IDs for you.

Create real objects as Tenant A through the normal endpoints, harvest every identifier the API hands back, then replay all of them as Tenant B. On every CI run. A 200 on that replay isn't a theoretical finding. It's a cross-tenant read with a reproducible curl attached.

Capture references, especially the boring ones

Response body IDs are the start. Also grab nested child objects, Location headers, pagination cursors, export file URLs, webhook payloads.

Capture children aggressively. GET /invoices/{id} gets scoped because it's the obvious route. The line-item route, the attachment download, the avatar URL three levels deep in a response: those get forgotten, because whoever wrote them was thinking about the object graph, not the tenant graph.

Generate the matrix from your OpenAPI spec

Hand-maintained test lists rot. Generate the authorization matrix from your spec so coverage tracks the API surface:

import json, yaml

spec = yaml.safe_load(open("openapi.yaml"))
rows = []
for path, ops in spec["paths"].items():
    for method, op in ops.items():
        path_params = [p["name"] for p in op.get("parameters", []) if p.get("in") == "path"]
        if path_params:  # object-addressing routes are the ones that can leak
            rows.append({
                "method": method.upper(), "path": path, "params": path_params,
                "expected_cross_tenant": [404],
            })
json.dump(rows, open("authz_matrix.json", "w"), indent=2)
Enter fullscreen mode Exit fullscreen mode

Every route that takes an object ID gets an explicit expected status for cross-tenant replay. That explicitness matters. A 403 confirms the resource exists; a 404 doesn't. Pick one policy per route class. I default to 404 on detail reads, but consistency beats the specific choice. The finding I see most is sibling routes that disagree: /invoices/{id} returns a disciplined 404 while /invoices/{id}/pdf returns 403, and now the PDF route is an existence oracle. Manual spot-checks miss that. The harness flags it on the first run.

Normalize, then sweep the ugly routes

Raw response diffing will bury you. Timestamps, request IDs, ETags, and signed URLs change on every call, and a month of noisy failures gets the job disabled. Strip volatile fields, then compare three signals: status against policy, normalized body match against A's own response to the same reference (a hash match means B received A's actual data, full stop), and body shape, since a 200 with an empty payload on a detail route is still an existence leak.

Budget a day tuning the volatile-field list per API. That's where the false positives live.

Then sweep where isolation actually dies. Detail GETs get the attention, but the boring machinery leaks first:

  • Exports and async reports. The job is created under one tenant context, the artifact fetched through another. Signed URLs sometimes authorize whoever holds the link.
  • List and search. Replay A's cursors, filters, and sort params as B. Check pagination edges.
  • Webhooks. Register B's listener, trigger events involving A's objects where the product allows cross-references. A's data in B's payload is a cross-tenant read through the outbound channel.
  • Per-tenant subdomains. Replay a session minted on A's subdomain against B's. Tokens not scoped at issuance often work across both.

A manual pentest answers this question once and goes stale the day you ship the next endpoint. This runs on every release for the cost of a CI job.

  • Mint as A, capture every reference the API emits, replay as B. That loop is the whole test.
  • Encode a 403-vs-404 policy per route class and alert on sibling routes that disagree.
  • Normalize before you diff anything, or false positives will get your harness muted.
  • First stop on your next review: export download URLs and async job IDs.

Where do you land on 404 vs 403 for cross-tenant detail reads? I'll argue consistency beats either choice, but I've watched good teams fight about this.

Longer writeup if you want the full argument: https://axeploit.com/blog/prove-tenant-isolation-without-a-pentest-mint-as-tenant-a-replay-as-tenant-b

Top comments (1)

Collapse
 
raknaos profile image
Raknaos

The existence oracle angle is the useful one here. Once identifiers are unguessable, teams assume the enumeration surface is gone, and then a disciplined 404 on the detail route next to a 403 on the PDF route hands back exactly the enumeration that UUIDs were supposed to close — different status code per object is a valid answer to "does id X exist" at any rate you like.

One thing your matrix would catch that most do not: the write half. A cross-tenant GET that 403s beside a PATCH that 204s is the same bug class and only one of them costs a customer. How do you keep the replay CI-safe once it touches writes — a fresh tenant pair seeded per run, or a whitelist restricted to idempotent reads? The second option is the one that usually survives contact with a shared staging database, and it is also the one that quietly stops covering the dangerous routes.