fixmyvibe.codes
<  Back to Blog

Can Customer A See Customer B's Data? Multi-Tenant Bugs in AI-Built SaaS

8 min read By Ivan Vetrau
multi-tenant saas-security data-isolation vibe-coding

Multi-tenant SaaS security comes down to one question: when a customer logs in, can they see anything that belongs to someone else? If your app was built with Cursor, Lovable, Bolt or a similar tool, we would not assume the answer is no. We open AI-built B2B products every month, and cross-account data exposure is the bug that makes us stop the review and phone the founder.

It is also the bug that never shows up in a demo. Everything works. Every screen loads. Only when a second customer arrives does the problem exist at all.

What “multi-tenant” means in plain English

Think of an apartment block. One building, one set of pipes, one front door, but each flat has its own lock. Your SaaS is the building. Each customer company is a tenant, and their projects, invoices, contacts and files are the furniture inside their flat.

Nearly every SaaS product works this way, because running a separate copy of the app per customer would be ruinously expensive. All the customers share one database, one codebase and one server. What keeps them apart is a rule the code has to enforce on every single request: “this row belongs to tenant 42, and only tenant 42 may see it.”

That rule is called data isolation. It is not a feature you switch on. It is a check that has to be present in every query, every endpoint and every cached response. Miss it in one place and the wall has a hole in it.

AI tools are bad at this for a simple reason. You ask for “a dashboard that lists invoices” and you get exactly that: a page that lists invoices. Nobody said “only this customer’s invoices”, so the model, which is optimising for the thing you asked for, often does not add it. The prompt described a working feature, not a boundary.

How AI-generated code leaks between accounts

We see the same four patterns again and again. Each is an ai saas data leak waiting for a curious user.

IDs in URLs with no ownership check

Your app has a page at /invoices/1042. The code fetches invoice 1042 and shows it. What it often forgets is to ask whether the logged-in user is allowed to see invoice 1042. Change the number to 1043 and you are reading someone else’s invoice.

Security people call this an insecure direct object reference. We call it the most common serious bug in AI-built apps. It works because the login check answers “are you a user?” and never “is this yours?”. Those are different questions, and generated code frequently answers only the first. Our guide to adding authentication explains why proving who someone is and deciding what they may touch are two separate jobs.

Missing filters on list endpoints

The page for a single record might be fine while the list is not. A GET /api/projects endpoint that runs select * from projects returns every project in the database. The front end may then filter the list down to the current customer, which looks correct on screen. But the full list still travelled to the browser, and anyone who opens the network tab can read it.

Filtering in the interface is decoration. The server has to do the filtering.

Shared caches and background jobs

This one is subtler and, in our view, nastier. Someone asks the AI to make a slow page faster, and it adds a cache. The cache key is the page name, say dashboard-stats, with no tenant in it. Customer A loads the dashboard and the numbers are stored. Customer B loads it a minute later and is served customer A’s numbers.

The same thing happens with exports, email digests and scheduled jobs that loop over “all records” without grouping by tenant. Nothing crashes. The wrong data simply lands in the wrong inbox.

Admin endpoints anyone can call

Generated apps love an admin panel. The button is hidden from normal users, so the model considers the job done. But the endpoint behind the button, something like /api/admin/users, frequently has no role check at all. Hiding a button is not access control. If the URL responds to any logged-in account, or worse, to anyone, then every customer is one guess away from your whole user table.

If you are on Supabase, there is a fifth pattern worth its own read: row-level security switched off, or turned on with a policy that says “allow everything”. We cover that in common Supabase mistakes in AI-generated code, and we would put it near the top of the list for anyone with a shared database.

A two-account test any founder can run

You do not need to read code for this. You need two browsers and about twenty minutes.

  1. Create two separate customer accounts, A and B, using two different email addresses. Put them in different organisations, not two users in the same one.
  2. In a normal window, log in as A. Create some recognisable data: a project called “Alpha Secret”, an invoice, an uploaded file.
  3. In a private window, log in as B. Create “Bravo Secret” and a few other records.
  4. Now, as B, look at every list and dashboard. Is anything of A’s visible? Check search, exports and any “recent activity” feed too.
  5. As A, open a single record and copy its URL. Paste that URL into B’s window. You should see an error or a redirect. If you see A’s record, you have found the first bug.
  6. Look for the same thing in file downloads. Copy the link to A’s uploaded file and open it as B, and then in a window where nobody is logged in.
  7. Try the numbers trick. If a URL ends in a number, add one or subtract one and see whose record turns up.
  8. Log out completely and paste A’s record URL. Anything other than a login prompt is a failure.

Do not stop at the pages you can click to. Ask whoever built the app, or the AI itself, to list every API endpoint, and try the risky ones. Our article on testing a vibe-coded app when you can’t read code has more tricks for this kind of hands-on poking.

Passing this test does not prove the app is safe. It catches the lazy leaks, which are the ones attackers try first. A failure, though, is conclusive. If B can see A’s data in twenty minutes with a browser, so can anyone else.

What a leak means legally

We are not lawyers, and you should ask one. But a founder should know the shape of the problem before it arrives.

If customer data reaches someone who should not have it, that is very likely a personal data breach under GDPR, provided the data identifies people, and in B2B software it nearly always does: names, email addresses, phone numbers, notes. Once you become aware of it, the clock is short. In the UK and EU you generally have 72 hours to notify the regulator if the breach poses a risk to individuals, and you may have to tell the affected customers directly as well.

Then there are your contracts. Most B2B customers sign terms, or a data processing agreement, promising confidentiality. A leak between two tenants can breach those terms even if no regulator ever hears about it. Enterprise buyers will also ask about tenant isolation during security questionnaires, and “we think it’s fine” does not survive a follow-up question. Our GDPR guide for vibe coders covers the wider obligations, including what to record and when to report.

The cost that rarely appears in a legal summary is trust. One customer finding another’s invoices tends to be the last thing they tell you before cancelling.

The proper fix

Patching the URL you happened to test is not a fix. The bug is structural, so the repair has to be structural too. Here is what we do.

Put the tenant on every row. Every table that holds customer data gets a tenant identifier column, set on creation and never trusted from the browser. The server works out which tenant you belong to from your session, not from a value you send.

Enforce it in the database, not only in the app. With Postgres, including Supabase, row-level security policies mean the database itself refuses to return another tenant’s rows, even when a developer forgets a filter in the code. This is the safety net. Application-level checks are still needed, but the database catches the mistakes that will inevitably happen later.

Check ownership on every request. Every endpoint that takes an ID must confirm that the record belongs to the caller’s tenant before doing anything. We route this through one shared function so it cannot be forgotten in the fortieth endpoint.

Put the tenant in every cache key and job. Cached values, queue messages and scheduled tasks all carry the tenant identifier. If a job cannot say whose data it is touching, it does not run.

Lock down the admin routes. Role checks happen on the server, per endpoint. Hidden buttons are a courtesy to users, not a control.

Write the leak tests once, and keep them. The two-account test above becomes an automated test that runs before every deploy. AI tools will keep changing your code, and the next generated feature can reopen a wall we just closed. A test that fails loudly is the only thing that stops a regression from reaching customers.

None of this is exotic. It is the ordinary discipline that a first-year backend developer would apply by habit and an AI tool applies only when told to. The trouble is that founders do not know to tell it.

If you have paying customers on a shared app and have never run the two-account test, run it this week.


Serving multiple customers from one app? Let us check the walls between them. Free. Send us your project and we will tell you what we find.

Need help with your AI-generated code?

We fix bugs in vibe-coded apps and make them production-ready. Free code assessment — no commitment required.