Supabase lets the browser query Postgres directly through its Data API, using a publishable key (the anon key, in older projects) that ships in the front-end code and is designed to be public. There is no application server in the middle checking who is allowed to see what. Row level security does that job, inside the database, on every request.

AI coding tools are very good at producing working Supabase apps. Working and correctly restricted are different tests, and only the first one fails loudly. These are the mistakes that pass the first test and fail the second.

1. Row level security never switched on

Supabase's Table Editor enables row level security when it creates a table. A table created with SQL, in the SQL editor or through a migration, does not get it, and Supabase's documentation says so plainly: when you create a table with SQL, enable it yourself. Generated code usually creates tables with SQL.

alter table public.invoices enable row level security;

The Security Advisor in the Supabase dashboard lists every table in the public schema without row level security as an error. It is the fastest check there is, and it is worth running after every migration, not once.

2. Switched on, then opened straight back up

With row level security enabled and no policies, no data is reachable through the API with the publishable key. The app breaks immediately. The quickest change that makes the error go away is a policy like this:

create policy "Allow all" on public.invoices
for select using (true);

It fixes the error. It also lets anyone with the publishable key read every invoice, which is the same position as before, with an extra line of SQL to make it look deliberate. using (true) is correct for genuinely public data, such as a product catalogue. It is never correct for anything with a customer's name on it.

3. No role named on the policy

A policy that compares auth.uid() to a user column looks safe. For a signed-out request, auth.uid() returns null, and null never equals anything in SQL, so the policy quietly returns nothing rather than erroring. Supabase recommends naming the role every policy applies to, so the intent is explicit and anonymous requests never evaluate it at all.

create policy "Users read their own invoices"
on public.invoices for select
to authenticated
using ( (select auth.uid()) = user_id );

4. Reads are covered, writes are not

Policies are per operation. A table with a careful select policy and a generous insert or update policy lets users write where they cannot read. Three details catch people out:

  • Inserts need a with check clause, or a user can create rows that belong to someone else.
  • Updates need both clauses. using decides which rows a user may change. with check decides what the row may look like afterwards, which is what stops a user reassigning their row to another account.
  • Updates need a matching select policy. Supabase's documentation notes that without one, updates do not work as expected. The common reaction to an update that silently fails is to widen the policy until it works.

5. Roles read from user metadata

Supabase Auth stores two metadata fields. raw_user_meta_data can be updated by the signed-in user. raw_app_meta_data cannot. Supabase's documentation describes the first as not a good place for authorisation data.

A policy that grants admin access when user metadata says role = 'admin' works perfectly in testing. It also lets every user make themselves an admin with a single API call. Roles belong in app metadata, or in a table that users cannot write to.

6. Views and functions that bypass the policies

Views bypass row level security by default, because they are usually created by the postgres user and run with its privileges. A view built to make the dashboard query simpler can expose every row of the tables underneath it. On Postgres 15 and later, the fix is to create views with security_invoker = true, so they run with the permissions of whoever queries them.

create view public.invoice_summary
with (security_invoker = true)
as select id, user_id, total from public.invoices;

Functions declared security definer also run with their owner's privileges. Some genuinely need to. Each one is worth reading before it ships. The Security Advisor flags security definer views, and flags any view that exposes auth.users.

7. Policies that are correct and slow

A policy runs on every row a query touches. Two changes from Supabase's own guidance make a large difference as tables grow: an index on every column a policy filters on, such as user_id, and wrapping functions like auth.uid() in a select, as in the example above, so Postgres evaluates it once per statement instead of once per row.

This matters for security as well as speed. A slow policy is the policy most likely to be loosened during an incident, by someone trying to get the app back up.

8. Storage forgotten

Supabase Storage uses the same mechanism: policies on the storage.objects table. Without policies, uploads to private buckets are refused, which prompts exactly the same quick fix as mistake 2. Public buckets skip policies entirely, and anything in them is readable by anyone with the URL. Customer uploads belong in a private bucket, with policies that match the table they relate to.

9. Tested only as the person who built it

The builder tests as themselves, sees their own data, and everything works. The tests that matter are the other two: signed out, and signed in as a second user trying to read the first user's rows through the API. If either one returns data it should not, the policies are wrong, whatever the app's screens show.

None of these mistakes comes from carelessness. Each one is the reasonable, fastest response to an error message. Put together over a few weeks of shipping, they produce an app where every screen works and every customer can read every other customer's data.

Why this is an operations problem

Row level security is not set once. Every new table, view, function and bucket is a fresh chance to repeat one of the mistakes above, and AI tools make new tables very quickly. The durable fix is a routine: the Security Advisor after every migration, the two-user test after every schema change, and someone whose job it is to notice when either fails.

That routine is part of what we take on when we run production for AI-built apps, alongside monitoring, incident response and backups. The rest of the security picture is in our security checklist for AI-built apps, and production support for AI-built apps explains how we work. Critical Support Lite is the service built for this stage.