Your app has customers. Customer A logs in and sees their settings. The question that matters before launch is whether Customer A can see or change Customer B's settings. If the answer is yes, one curious user, or one script, can reach every account.
OWASP's Top 10 (2021 edition) ranks Broken Access Control first1, and one of its standard examples is changing an identifier to view or edit someone else's account. AI builders produce this flaw readily, because a prompt like "let users update their settings" gets a working screen, and nothing in the loop asks "whose settings?" You can test for it yourself in one sitting.
The two-account test
You need two real accounts on your app, A and B, ideally in different browsers or profiles.
- As A, open the screen you want to test (settings, profile, billing, a project).
- Open the browser's Network tab, make a change, and find the request that saves it.
- Note the ID in that request: in the URL (
/api/settings/123), in the body ({"user_id": "..."}), or in a query string. - Get B's ID for the same kind of record. Your own database or B's network request shows it.
- Replay A's request, still using A's login, with B's ID swapped in.
The result decides it:
- Server returns 403 or 404, B's data unchanged: the check works for this endpoint.
- Server returns 200 and B's data changed or returned: any logged-in customer can touch any other customer's record on this endpoint.
Repeat for every endpoint that takes an ID: read, update and delete. Do it for settings, profile, billing details, files, invoices and team membership. Check the reads too. Reading another customer's data is a breach on its own.
Why this happens in AI-built apps
Three causes cover most cases.
Cause 1: the database rule says "yes to everyone"
With Supabase, Row Level Security decides who can touch which row. A policy written using (true) allows everyone. Policies copied from a tutorial, or generated to make an error go away, often end up that way. The fix is to scope every policy to the owner:
alter table public.settings enable row level security;
create policy "owner reads own settings" on public.settings
for select using (auth.uid() = user_id);
create policy "owner updates own settings" on public.settings
for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
The with check clause matters on update: it stops a user from changing a row's user_id to someone else's. The longer version of this checklist is in Is my Supabase secure?.
Cause 2: your server uses a key that skips the rules and trusts the ID in the request
A server route that uses the Supabase service_role key bypasses Row Level Security completely. That is fine on the server, as long as the code itself checks who is asking. This pattern is the problem:
// Trusts the user_id sent by the browser
const { user_id, theme } = await req.json()
await admin.from('settings').update({ theme }).eq('user_id', user_id)
Anyone who edits user_id in that request changes someone else's row. The rule is simple: the identity comes from the session, never from the body.
const user = await getSessionUser(req) // from the verified session
if (!user) return new Response('unauthorized', { status: 401 })
const { theme } = await req.json()
await admin.from('settings').update({ theme }).eq('user_id', user.id)
Cause 3: the client sends fields it should not control
If an update handler takes the whole request body and writes it, a user can add role: "admin", plan: "enterprise" or org_id: "<someone else>" to the payload. Accept an explicit list of fields and ignore the rest.
What a guess-proof ID does and does not do
Switching from numeric IDs to UUIDs makes enumeration harder. It does not make the endpoint safe, because IDs show up in shared links, email, logs and API responses. The server must check ownership on every request, regardless of how hard the ID is to guess.
What your own data says about the anonymous version of this
The two-account test covers logged-in customers. The no-login version is common too. In our October 2026 scan of 500 live AI-built apps, 177 used Supabase, and 31 of those 177 (1 in 6) had at least one table readable with no login at all. Eight of the 177 had a sensitive table in that state. The method and denominators are on the dataset page, and the number measures only anonymous reads, not cross-account access, so treat it as a floor on the wider problem.
What the free check and the $47 scan can and cannot tell you
An outside scan has no login, so it cannot run the two-account test. It cannot replay Customer A's session with Customer B's ID. What it can check is the no-login side.
The free Leak Check looks at your live app from the outside in under 60 seconds, including whether a secret key is sitting in your client bundle and whether Supabase exposure shows up.
The $47 Complete Scan probes every common table for anonymous reads and writes, plus storage buckets and auth endpoints, with the exact fix for each finding. That is the stranger-with-no-account side of this problem. The logged-in A-versus-B side is still the two-account test above.
Next step
Run the two-account test on your three most sensitive screens this week, then run the free Leak Check for the no-login side. If either finds something and you want it read and fixed from the inside, with your table policies and secrets reviewed, that is the written Ship Check.
Related: Is my Supabase secure?, an exposed anon key with no RLS behind it, and customer paid but is still on the free plan.
Sources
Market figures above are marked with a number that links here. Our own prices are marked as ours and come from our published pricing.
- 1. A01:2021 Broken Access Control, OWASP, checked November 5, 2026. OWASP Top 10 (2021 edition) ranks Broken Access Control first; its listed examples include viewing or editing someone else's account by changing an identifier.