← Back to posts

13 min read

Prove Tenant Isolation Without a Pentest: Mint as Tenant A, Replay as Tenant B

Filed under API Auth

Your object IDs are UUIDs, so the classic IDOR recipe (increment 10001 to 10002, watch another tenant's data appear) does not apply to your API. A full manual pentest would answer the question, but it is priced like the bespoke consulting it is and goes stale the day you ship the next endpoint. The workable middle ground: mint real objects as Tenant A, capture every reference the API hands back, and replay all of them as Tenant B, on every CI run.

This post is that method, end to end.

Why the advice you already found doesn't fit

What the existing advice actually delivers

The search results for this problem split into camps that all miss the working tester's situation. The defensive coding guides are the most polished of the bunch. The NENE2 reference implementation, for example, resolves tenant identity from X-Tenant-Id and X-User-Id headers, validates them with a digit check plus a range check, and rejects "1 OR 1=1", negative numbers, floats, and 20-digit overflow values with a 401. A tenant_id smuggled into the POST body is ignored on write. That is genuinely good server-side hygiene, and their companion guide encodes it as a 12-case attack checklist (ATK-01 through ATK-12) with expected status codes. But it answers the developer's question, "how do I build this correctly," not your question, "how do I prove, repeatably, that the thing we already built holds."

Then there is the manual methodology camp. Ironimo's July 2026 guide is the one substantive piece, and its architecture taxonomy is worth stealing: shared database with shared schema (application-only filtering, the most common early-stage setup and the ugliest), schema-per-tenant (where you can lean on PostgreSQL row-level security), and database-per-tenant (where the residual risk shrinks to connection-string resolution from a header or JWT claim). Their framing of the shared-schema risk is the sharpest one-line summary I have seen:

"A single missing WHERE tenant_id = ? clause anywhere in the codebase exposes every tenant's data in that table."

True. But the guide's testing advice is manual pentest reasoning: probe tenant-context inputs by hand, swap IDs in captured requests, document findings. Valuable once a year. Useless as a regression net.

The rest is pentest-scoping marketing and functional QA content. None of it tells you how to automate the actual attack when enumeration is impossible. So here it is.

Capture references instead of enumerating them

Opaque IDs kill enumeration, and every junior-level IDOR technique dies with it. Fine. Stop enumerating.

The API itself will mint valid references for you. Create objects as Tenant A through the normal endpoints, then harvest every identifier the API emits: response body IDs, nested child objects, Location headers, pagination cursors, export file URLs, webhook payloads. Store them. Those captured references are your ammunition for the replay phase, where a second session, authenticated as Tenant B, requests each one.

This inverts the usual test shape. You are not guessing IDs; you are asking whether the authorization layer correctly rejects IDs you know are valid because you just created them. A 200 on that replay is not a theoretical finding. It is a cross-tenant read with a reproducible curl command attached.

Capture child references aggressively. The detail route GET /invoices/{id} usually gets scoped because it is the obvious one. The line-item route, the attachment download, the avatar URL embedded three levels deep in a response: those are where scoping gets forgotten, because the developer who wrote them was thinking about the object graph, not the tenant graph.

Generate the authorization matrix from the OpenAPI spec

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

python
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)

Every route that takes an object ID gets a row with an explicit expected status for cross-tenant replay. That explicitness is the point. NENE2's guides scale this idea to 34 tests and 133 assertions, and their status policy is opinionated in a way yours should be too:

"403 reveals the resource exists; 404 prevents enumeration."

Pick one policy per route class and encode it. I default to 404 for detail reads on cross-tenant references, for exactly the reason in that quote. But honestly, the specific choice matters less than consistency, because the most common real-world finding is not the wrong code. It is sibling routes that disagree. GET /invoices/{id} returns a disciplined 404 while GET /invoices/{id}/pdf returns 403, and now an attacker can enumerate invoice IDs through the PDF route's existence oracle. Your harness flags that automatically the moment you encode the policy, which no amount of manual spot-checking will do reliably.

While you are at it, add rows for tenant-context tampering beyond the URL. Ironimo's checklist of resolution inputs is the right one: subdomain, header, JWT claim, URL parameter, session value. Plus request-body fields named tenantId, organizationId, orgId, accountId, workspaceId, or companyId, which you capture from a legitimate request and swap for B's value. Any response change counts as a finding, even an error message that leaks tenant metadata. And if your product uses per-tenant subdomains, replay a session minted on A's subdomain against B's. Tokens that are not scoped at issuance often work across subdomains, and that is a full isolation bypass wearing a UX feature's clothes.

Normalize before you diff anything

Raw response diffing will bury you. Timestamps, request IDs, ETags, signed URLs, and cursors change on every call, and within a month of noisy failures someone will disable the job. Strip volatile fields and canonicalize before comparing:

python
import json, re

UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
VOLATILE = {"createdAt", "updatedAt", "requestId", "etag", "cursor", "signedUrl", "traceId"}

def normalize(x):
    if isinstance(x, dict):
        return {k: normalize(v) for k, v in sorted(x.items()) if k not in VOLATILE}
    if isinstance(x, list):
        return [normalize(v) for v in x]
    if isinstance(x, str) and UUID.match(x):
        return "<ref>"
    return x

Then compare three signals, not one. Status code against the matrix policy. Normalized body from B's replay against the normalized body from A's own request to the same reference (a hash match means B received A's actual data, full stop). And body shape: a 200 with an empty payload on a detail route is still an existence leak worth a human look. My classification rule is simple: any cross-tenant 2xx on a detail route with a body match is a build failure; any 2xx without a match, or any status that breaks policy, goes to a review queue. Budget a day of tuning the volatile-field list per API. That is where the false positives live, and pretending otherwise is how these harnesses get abandoned.

Sweep the routes where isolation actually dies

Detail GETs get the attention, but the boring machinery leaks first. Ironimo flags this for schema-per-tenant setups and it generalizes: reporting features, data exports, and admin tooling routinely bypass per-tenant routing because they were built as afterthoughts with separate code paths.

So your sweep covers more than detail routes:

  • List and search endpoints. Replay A's cursors, filters, and search strings as B. Check pagination edges and sort parameters, and test parameter validation while you are there. NENE2 caps limit at 100 and returns 422 for negative, float, and overflow values; if your list endpoint accepts limit=99999999, that is a data-harvesting amplifier sitting next to your isolation logic.
  • Exports and async reports. These are my personal first stop, because the job is created under one tenant context and the artifact is fetched through another. Create an export as A, then replay the job ID and the download URL as B. Signed URLs sometimes authorize whoever holds the link, tenant be damned.
  • Webhooks. Register a webhook as B pointing at a listener you control, then trigger events involving A's objects where the product allows cross-references (shared templates, marketplace apps, integrations). If A's data shows up in B's payload, you have a cross-tenant read through the outbound channel. Outbound integrations are their own tenant boundary problem, and I have written about why third-party SaaS integrations are your biggest attack surface before; the same logic applies inside your own product.

The harness skeleton

Replay verdict logic for one captured reference

The core loop is small enough to paste:

python
import requests, sys

BASE = "https://staging.internal"
A = {"Authorization": "Bearer " + open("token_a").read().strip()}
B = {"Authorization": "Bearer " + open("token_b").read().strip()}

refs = {}  # filled by minting objects as A through the normal API

def replay(path):
    ra = requests.get(BASE + path, headers=A)   # A reads its own object
    rb = requests.get(BASE + path, headers=B)   # B replays the reference
    a_body = json.dumps(normalize(ra.json()), sort_keys=True)
    b_body = json.dumps(normalize(rb.json()), sort_keys=True)
    if 200 <= rb.status_code < 300:
        if b_body == a_body or a_body in b_body:
            return f"FAIL {path}: B received A's data"
        return f"REVIEW {path}: 2xx on cross-tenant read"
    if rb.status_code != 404:                   # your encoded policy here
        return f"REVIEW {path}: status {rb.status_code} breaks policy"
    return None

failures = [v for v in (replay(p) for p in refs.values()) if v]
print("\n".join(failures)); sys.exit(bool(failures))

If your team lives in Burp, the same logic maps to two session handling rules (one login macro per tenant) plus an extension that replays captured references. Several community extensions already do the capture-and-replay half. None of them encode your status policy or your matrix, which is why the script version survives contact with CI and the Burp version stays a manual exercise. Use Burp for exploration, the script for proof.

What this proves, and what it cannot

Pushback I hear from skeptics, and it deserves a straight answer: this is not a proof of tenant isolation. It is a regression net over the routes in your spec, and it will miss things. Undocumented endpoints and GraphQL resolvers never become matrix rows. Sharing and collaboration features sometimes need a third tenant to expose (B sees A's data only after C interacts with it), so add Tenant C fixtures if your product has any sharing concept. Caches, search indexes with sync lag, and analytics pipelines are separate trust boundaries with flaky timing; replay against them, but expect to retry. And if nobody on your team owns security at all, run a 90-minute threat modeling pass first so you know which resources deserve fixtures on day one.

Keep the annual manual pentest. It finds the logic bugs your matrix never imagined. The harness makes that engagement cheaper, because the tester starts from your coverage map instead of building one on your dime.

Run it in CI or it did not happen

Seed two tenants in an ephemeral environment per pipeline run, execute mint-capture-replay, fail the build on any FAIL line, and commit the matrix file to the repo so reviewers see coverage change with the spec. The shared-schema attack surface grows with every new query your team writes, which means a once-a-year check is structurally guaranteed to be stale. This is the same argument as moving from quarterly scans to a continuous threat exposure pipeline: the finding is only worth what you paid for it if it still holds after the next merge.

Cross-tenant and object-level failures are the kind of finding a login-aware scan should produce. Axeploit's API security checker is built for that. It will not replace the matrix above, but it will tell you in an afternoon whether the matrix has anything to find.

Key takeaways

  • Opaque IDs kill enumeration, so invert the attack: mint objects as Tenant A, capture every reference the API emits, and replay each one as Tenant B. This is the only workable IDOR pattern with UUIDs.
  • Generate the authorization matrix from your OpenAPI spec and encode an explicit status policy per route. Sibling routes that disagree on 404 vs 403 are existence oracles, and consistency is the actual finding.
  • Normalize volatile fields before diffing. A raw byte diff drowns in false positives and the job gets disabled within a month.
  • Sweep list, search, export, report, and webhook paths first. They bypass per-tenant routing far more often than detail GETs, and async artifacts often authorize whoever holds the URL.
  • Treat the harness as a regression net, not a proof. Keep the manual pentest for the logic bugs your matrix never imagined.
Get started

Integrate Axeploit into your workflow today