Multi-tenant applications often look safer than they are. The navigation shows only the current organisation. The account switcher lists the right names. Every normal click includes the expected tenant identifier.

Send the same request outside the normal click path and change the tenant or record identifier. The server's response reveals whether the interface or the API owns the boundary.

Identity and tenancy are different facts

Authentication establishes who the user is. Tenant authorisation establishes which organisation, workspace or account that user may act inside.

An API that checks only “is this person signed in?” has completed the first job and skipped the second. An API that accepts account_id from the request and uses it without checking membership lets the browser choose its own boundary.

Resolve the tenant from trusted membership data on the server. When the route includes a tenant ID, treat it as a requested resource and verify the signed-in user's relationship to it.

Object identifiers need the same check

Many cross-account failures do not mention a tenant directly. The request asks for /api/invoices/inv_8321, and the server loads that invoice by ID. If it does not also constrain the lookup to an authorised tenant, any valid invoice identifier may work.

The safer query expresses both facts together: find this invoice inside an account the current identity may access. That approach avoids loading the object first and checking later, which can leak timing, metadata or error differences.

Roles belong to a scope

“Admin” is incomplete without saying admin of what. A user may administer one workspace and remain an ordinary member of another. Global support roles may have a separate, audited path.

Model the scope explicitly. Avoid a convenient boolean such as is_admin when the permission actually depends on organisation membership, environment or a specific resource. This becomes especially important for invitations, billing changes, exports and user removal.

Background work can cross the boundary too

Tenant checks are not only for browser-facing routes. Queue consumers, scheduled jobs, report generators and webhooks often load records using IDs captured earlier.

Carry the tenant context with the job and verify it again when the job runs. A stale or tampered message should not be able to move a document, report or notification into another account.

Build a small isolation test matrix

Use two organisations with similar data and at least three users:

For each sensitive action, test the intended case and one cross-boundary case. Alice should not read organisation B's invoice, invite a user to B, export B's records or update B's payment settings. Bob should not inherit Alice's administrative actions merely because they share a tenant.

Use direct API requests. Interface tests are useful for user experience, but they cannot prove that a hidden route rejects an unauthorised caller.

Make failures useful without leaking data

Return a consistent denial when the caller cannot access a resource. Revealing “that record exists but belongs to another company” gives away information the tenant boundary was meant to protect.

Log enough internal context to investigate the denial: request ID, authenticated user ID, requested tenant and route. Avoid placing personal data or full request bodies in logs.

Add a reusable test that fails if a future route forgets the tenant condition. Run it against browser routes, queue consumers and exports that read the same records.