One tenant reading another tenant's records
The concrete risk in a multi-tenant SaaS app is that customer A can read, and sometimes change, customer B's records by sending B's tenant ID to your API. This is the failure mode behind cross-tenant access: the route works perfectly for each tenant in isolation, every demo passes, and the leak only exists when a second tenant arrives and someone swaps an ID. That is exactly when a multi-tenant isolation check matters most, and exactly when most builds skip it.
The mechanism is a missing ownership check. A generated CRUD route reads something like a query parameter or path segment named org_id, tenant_id, account_id, or workspace, and then filters the database by that value. If the value comes from the request and the server never confirms that the authenticated user belongs to that tenant, then any logged-in user can supply any tenant's ID and get that tenant's rows back.
- Observable: the API accepts a tenant or org identifier from the caller and returns records scoped to it.
- Only possible until tested: whether the server validates that the caller owns that tenant. You cannot tell from the outside without trying it on your own account.
- This is an authorization gap, not an authentication one. The user is correctly logged in; the server simply trusts a tenant ID it should have verified. In OWASP terms this is Broken Object Level Authorization, the BOLA / IDOR class.
Why AI-built SaaS apps land here
AI scaffolding is very good at producing a working multi-tenant data model and the CRUD routes to go with it. Ask for "an app where each company has its own projects and tasks" and you get tables with a tenant foreign key, plus endpoints that filter by it. The generated query usually reads the tenant identifier straight from the request, because that is the most direct way to make the feature function on the first run.
The step that does not happen on its own is the check that ties the request's tenant ID back to the authenticated session. Generated code filters by the ID it was handed; it rarely derives the tenant from the user's own record and compares the two. With a single test account the difference is invisible, because your one account always passes its own ID.
- The route works for the current user, so the missing ownership check leaves no visible symptom.
- Tenant context is read from the request body, query string, or URL path rather than from the verified session.
- Fast deploy means the app is live and onboarding a second customer before anyone writes the test that would expose the gap. This is closely related to predictable object IDs and API enumeration; if record IDs are sequential, an attacker does not even need to guess a tenant ID.
The manual cross-tenant test on your own app
You can confirm isolation on your own app safely, with two accounts you control. Do this only against your own SaaS, never anyone else's.
Create two separate tenants: sign up account A and account B, ideally as different organizations. Put a distinctive record in B, for example a project named ZZ-isolation-probe. Now log in as A, open DevTools, and watch the Network tab while A loads its own data. Find the request that carries a tenant, org, account, or workspace identifier, and note where that identifier lives: query string, path segment, request body, or a header.
Replay that request as A, but substitute B's tenant ID (and, if relevant, B's record ID). Use the Network tab's copy-as-fetch or copy-as-curl, keep A's session cookie or token, and change only the tenant or object identifier. Then read the response.
- Suspicious result: A's request returns B's records, including the ZZ-isolation-probe you planted. That is a confirmed cross-tenant read. If a write request such as PATCH or DELETE against B's record also succeeds, isolation is broken for writes too.
- Safer result: the server responds with 403, 404, or an empty set when A asks for B's tenant. A 404 that hides existence is fine; what you want is the absence of B's data in A's hands.
- Repeat per endpoint. Isolation is enforced route by route, so a list endpoint being safe tells you nothing about the detail, update, export, or search endpoints. Check each one that takes an ID.
Derive the tenant from the session, not the request
The fix is to stop trusting tenant identity that arrives in the request. On every protected route, derive the tenant from the authenticated session server-side, then either ignore any tenant ID the client sent or verify it matches. Concretely: look up the current user, read their tenant from your own records, and scope the query to that value rather than to a parameter the caller controls.
Where you have a database that supports it, push the rule down into the data layer too. Row Level Security can enforce that a query only ever returns rows whose tenant column matches the caller's tenant, so an application bug cannot silently widen access. Treat this as defense in depth, and be aware it has its own sharp edges; see Supabase RLS policy pitfalls for the ways a policy can look enabled while still letting rows through.
- Resolve tenant context from the verified session on the server; never accept it as the source of truth from the client.
- If a request must include a tenant or object ID, validate ownership before reading or writing, and return the same not-found response for both missing and unauthorized so you do not leak existence.
- Add an automated test that does the two-account swap above, so a future generated route that forgets the check fails CI instead of shipping.
Fixing isolation on your API does not cover every path to the same data. Background jobs, webhooks, admin tooling, direct database access, and exports can each bypass the route-level check you just added. Authorization has to be enforced everywhere a tenant's rows are reachable, not only on the endpoints a browser happens to call.
Where VibeCodeGuard fits, and what it does not prove
VibeCodeGuard's Launch Check scans your public surface, so it can flag the conditions that make cross-tenant access more likely: API routes that respond without authentication, and permissive or wildcard CORS that widens who can call them. Those are real, observable signals that an endpoint is exposed and worth hardening before you onboard a second customer.
What it cannot do is confirm tenant-ID ownership validation. Whether your server checks that the caller owns the tenant it asked for is logic that lives in your code and in your auth model. Verifying it requires the two-account manual test above, or a code-level review; a scan of the public URL cannot see inside that decision. Treat the Launch Check as catching the exposed-endpoint half of the problem, not the authorization half.
A clean Launch Check does not prove your tenants are isolated. The scan sees what is reachable from outside; it does not read your ownership checks or run the cross-tenant swap. Run the two-account test before onboarding tenant number two, and run a focused Launch Check against the public URL before sharing it so any open or unauthenticated endpoints surface first.
> launch check
Scan the public surface before launch.
Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.