X-Tenant-Id Header Bypass: When Org Context Comes From the Client
SaaS frontends send X-Tenant-Id, X-Organization-Id, X-Workspace-Id, or X-Account-Id so the API “knows” which customer context to use. Convenient. Also a trust boundary mistake when the server believes the header more than the session’s memberships.
Object IDOR changes a path ID. This bug keeps the path and swaps the tenant header. Same class of failure, different knob. Frontend route guards never save you here — the JSON API is the product.
Spot the pattern
In Burp, filter for custom headers on API calls after you switch workspaces in the UI. If the only thing that changes is X-Tenant-Id, that header is your target.
Also check:
- GraphQL extensions / operation payloads carrying
tenantId X-Forwarded-Tenantstyle headers added by a gateway you can spoof from the browser- Mobile clients that send tenant in a header the web app never needed
- WebSockets that set tenant once at connect time from a query param
- Batch “admin impersonation” headers left in staging builds
Some APIs accept the header only on certain routes (search, export, analytics). Test boring CRUD and the expensive report endpoints — isolation bugs love bulk reads.
Two-org proof
- Create Org A and Org B (or two trial workspaces). Plant a unique string in a private A record.
- As a user who only belongs to B, replay a B-normal request with
X-Tenant-Id: <A>. - Success means list/search/export returned A’s canary — or a write hit A’s object.
- Repeat with JWT from B but path IDs from A, header from A, and mixed combinations.
Try membership edge cases: invited-but-pending users, removed members whose JWT still validates, support roles, and service accounts. A user who left org A last week but still holds a cached tenant UUID is a realistic attacker.
Stop there if the API correctly returns 403. Move on. Do not brute foreign UUIDs hoping for noise.
Gateway vs application
I’ve seen APIs where the edge injects tenant from a signed cookie, but a direct hit to the upstream accepts a raw header. Host headers in scope docs matter: test both the public gateway and any documented internal-style base URL only if allowed.
Another variant: header selects DB schema / connection string. That is cross-tenant with teeth — treat carefully and minimize data read. One canary field is enough.
Severity
Cross-tenant PII or secrets → High/Critical. Harmless public catalog rows → Low or N/A. Write actions (delete, invite admin, billing) outrank read-only list leaks. If only the header changes the “branding” theme without data, skip the drama.
Cite OWASP’s IDOR testing guidance and recommend deriving tenant context solely from server-side session→membership maps, never from client headers alone. Bind every query with WHERE tenant_id = session.tenant enforced in the data layer.
One more habit: after a successful cross-tenant read, try the matching write once on a disposable field. Read-only leaks pay; unauthorized writes change the severity conversation fast.