The launch risk: tenant A reading tenant B's data
The concrete failure in a multi-tenant SaaS is one paying account reading or editing another account's records. Two unrelated companies sign up, and one of them can pull the other's invoices, customer list, or messages by changing a single value in a request. That is the thing a multi-tenant data isolation check is meant to catch before the URL spreads, because the first person to notice it is usually a customer, not you.
It helps to separate what is observable from what is only possible. From the outside, you can observe whether an API answers at all without a session, and whether it accepts a tenant id supplied by the caller. What you cannot observe from the outside alone is whether the server, after authenticating the caller, actually checks that the caller belongs to the tenant whose data they asked for. That second check is the one that fails quietly in generated CRUD, and confirming it holds takes a logged-in test, not a guess.
This is an authorization problem, in the OWASP Broken Access Control family and specifically the CWE class around authorization across ownership boundaries. The login can work perfectly. The session can be valid. The gap is that a valid session for tenant A is being trusted to act on tenant B's rows.
Why AI-built CRUD trusts the client's tenant id
When you prompt an AI builder for "a dashboard where each company sees its own projects," the fast path it generates is a set of CRUD endpoints that read a tenant id or org id from the request and query rows matching it. The app works on the first try: you log in as one account, you see that account's data, the demo looks finished. The query is filtering by the id the client sent, and the client sent the right one, so nothing looks wrong.
The missing piece is the ownership check. The server should derive the tenant from the authenticated session and refuse any request whose target rows belong to a different tenant. Generated code often skips that, because:
The result is an app where the tenant boundary is enforced by the UI showing you the right id, not by the server validating it. The UI is not a boundary.
- The tenant id arrives as a request parameter, header, or body field, and the handler trusts it instead of deriving it from the session.
- The first-pass query is "select rows where tenant_id equals the value I was given," which returns correct data in the happy path and hides that the value is attacker-controlled.
- Authentication was wired up, so it feels like access control is done; in fact authentication only proves who is calling, not what they are allowed to reach.
- Fast deploy means the public URL exists before anyone signs up a second tenant and tries to cross the line on purpose.
A test plan you run on your own app
You can confirm isolation on your own SaaS with two real accounts and no attack on anyone else. Create tenant A and tenant B, ideally with obviously different data in each, and keep both sessions handy in separate browser profiles or via saved request tools. Watch the requests in DevTools, Network tab, to learn the shapes.
Run these checks, comparing the safe result with the suspicious one.
If any single request returns or modifies another tenant's data, you have a reproducible isolation break, and it is a launch blocker.
- Swap the tenant or org id. As tenant A, find a request that includes a tenant id, org id, or workspace id, and replace it with tenant B's id while keeping A's own session and token. Suspicious: B's records come back, or the write succeeds. Safe: the server returns 403 or 404, or an empty result, because it ignored the supplied id and used A's session instead.
- Swap a record id across tenants. Take a record id that belongs to tenant B (a project, invoice, or document id you legitimately see while logged in as B) and request it while logged in as A, for example a GET on the object route with B's id. Safe: 403 or 404. Suspicious: A receives B's record. If the ids are sequential integers this is easy to probe; see predictable object ids and API enumeration for how guessable ids widen this.
- Re-use a session across tenants. Take tenant A's auth token or cookie and call an endpoint scoped to B. Safe: rejected. Suspicious: it answers, which means the endpoint is not tying the session to the tenant at all.
- Probe shared storage. If files live in a bucket, check whether one tenant's object URLs are guessable or listable, and whether an object created by B is reachable by A. Publicly readable buckets erase the tenant line entirely.
- Repeat per endpoint and per verb. Isolation is enforced (or not) handler by handler. A list endpoint that is locked down tells you nothing about the detail endpoint, the update endpoint, or the export endpoint. Test read, create, update, and delete separately.
How to fix it, and what the fix does not cover
The durable fix is one rule applied everywhere: derive the tenant from the authenticated session on the server, and never trust a tenant id supplied by the client to decide what a request may touch.
What the fix does not cover: isolation is authorization, not validation. Correct tenant scoping still lets an authenticated user inside their own tenant submit abusive input, escalate inside the tenant, or exhaust rate limits. It also does not address authentication weaknesses, like a guessable session token. Those are separate jobs.
- Scope every query by the session's tenant. Resolve the tenant from the verified session or token server-side, then filter every read and write by that value. If a client still sends a tenant id, treat it as a value to validate against the session, not as the source of truth.
- Check ownership on every object access. Before returning or mutating a record, confirm the record's tenant matches the caller's tenant. Return 404 rather than 403 for cross-tenant ids if you do not want to confirm a record exists.
- Enforce it at the data layer where you can. If you use Postgres with row level security, a policy that scopes rows to the caller's tenant turns isolation into a database rule instead of something every handler must remember. The Lovable and Supabase RLS note covers how those defaults leave tables open if RLS is off.
- Lock down storage. Make buckets private, scope object paths per tenant, and serve files through signed, time-limited URLs rather than guessable public links.
- Standardize the check so new endpoints inherit it. The most common regression is a new feature route that forgets the rule.
Where VibeCodeGuard fits, and what it does not prove
VibeCodeGuard's Launch Check scans the public surface, so it can flag the weak signals that often sit next to a broken tenant boundary: API routes that answer without authentication, endpoints that return data to an anonymous caller, open or wildcard CORS headers, and publicly readable buckets. Each of those is a reason to look harder at isolation, and the scan reports them with severity and fix direction.
What it cannot do is prove that tenant-ownership validation holds. That proof requires a logged-in, cross-tenant test with two real accounts, or a code review of how each handler resolves the tenant, which is exactly the test plan above. A clean scan means the obvious doors are not wide open; it does not mean tenant A cannot reach tenant B once both are logged in.
Next step: spin up two accounts on your own app, swap one id between them as described above, and watch what comes back. If A ever sees B's data, fix the ownership check before you share the URL. Run a focused Launch Check on the public URL in parallel so the adjacent exposures show up in your scan and not in a customer's report.
A clean Launch Check on the public surface is not a tenant isolation guarantee. The scan sees what an unauthenticated outsider can reach; it does not log in as two tenants and try to cross the line, and it does not read your authorization logic. Treat any flagged unauthenticated route or open bucket as a lead, not the whole answer.
> launch check
Scan the public surface before launch.
Get severity, evidence, and practical fix guidance for the checks VibeCodeGuard can run from the outside.