Before you read on

Supabase Row Level Security only protects data when every table has RLS enabled, a separate policy for each of the four SQL commands, and a tenant check that reads from a claim the user cannot write — and AI app builders reliably ship the first of those three and skip the other two.

  • The default is open. A table created by running create table in the SQL editor or in a migration has row security off until you enable it, so the public anon key can read it.
  • Broken object access is API1:2023. OWASP's API Security Top 10 for 2023 ranks Broken Object Level Authorization as the number one API risk, and a permissive RLS policy is exactly that bug moved into the database.
  • UPDATE needs two clauses. A policy with using and no with check lets a signed-in user rewrite a row's tenant id and move your data into their own account.
  • Wrap the auth call. Supabase's RLS performance guidance shows (select auth.uid()) is evaluated once per query instead of once per row, which on a large table is the difference between a fast page and a timeout.

A team I spoke with last year had a working B2B dashboard in nine days. Sign-up worked. Billing worked. The demo was clean enough to take to investors. Then one of their own engineers opened the browser console, copied the Supabase project URL and the anon key out of the JavaScript bundle, and pulled every row of the invoices table — for every customer on the platform — with four lines of code.

Nothing was hacked. Row Level Security was switched on for all 30-odd tables, exactly as the AI builder had reported. The policy underneath each one read using (true).

I run IndiaNIC, and a large share of the code my teams are now asked to review arrives written by a model rather than by a person. That pattern above is the most common finding we have, by a distance. It is also completely fixable in an afternoon, which is what the rest of this guide is for.

Three things AI code generators get wrong about RLS

Read enough generated migrations and the same three mistakes show up in nearly all of them. None of them is exotic. Each one is the shortest path to making a red error message turn green.

Myth one: enabling RLS is the security work

Enabling row security fits the lock. It says nothing about who holds the key. A table with RLS on and zero policies denies everyone, which breaks the app immediately, so the generator's next move is to write whatever policy makes the failure stop.

That policy is almost always create policy "Enable read access for all users" on public.invoices for select using (true); — and there is a second problem hiding inside it. A create policy statement with no to clause applies to public, which in Supabase includes the anon role. So the policy does not just open the table to logged-in users. It opens it to anyone holding the key that ships in your client bundle.

Myth two: the anon key is a secret

It is not, and it was never meant to be. Supabase's API keys documentation is explicit that the anon key is published to browsers and mobile apps; it identifies the project, not the person. Its safety rests entirely on the policies sitting behind it.

The dangerous version of this belief is subtle. Teams treat the key as one layer of defence and the policy as a second, when in reality there is only ever one layer. Guess wrong about that and you have built a bank vault with a photograph of a door.

Myth three: auth.uid() is enough

For a personal notes app, it genuinely is. For anything with organisations, workspaces, agencies, clinics or clients — which is most B2B software — it is only half a policy, because a row belongs to a tenant before it belongs to a user.

Here is where generated code does real damage. Reaching for a tenant id, a model will often write auth.jwt() ->> 'user_metadata'. But user_metadata is writable by the end user through a normal supabase.auth.updateUser() call. A policy that trusts it is a policy that lets any user assign themselves any tenant, in one line of client-side JavaScript, with no exploit required. Read the tenant from app_metadata, which only a service-role token can write, or better still from a memberships table you control.

What is Supabase Row Level Security, and what does it actually protect?

Row Level Security is a PostgreSQL feature that attaches a boolean expression to a table so the database itself decides, row by row, which rows a given role may read or change. Supabase converts the caller's JWT into a Postgres role and a set of claims, so the check runs inside the database — the same answer whether the request came from your Next.js server, the browser SDK, or a curl command someone wrote at 2 a.m.

Two clauses do the work, and confusing them causes most real breaches. using is a filter: it decides which existing rows the command is even allowed to see, and it governs SELECT, UPDATE and DELETE. with check is a gate: it validates the row as it will be after the write, and it governs INSERT and UPDATE. According to the PostgreSQL CREATE POLICY documentation, an UPDATE with no with check falls back to the using expression only for the old row — which is precisely the gap attackers walk through.

One more layer matters. RLS sits on top of ordinary GRANT permissions, not instead of them. If anon holds select on a table, RLS is the only thing standing between the public internet and those rows. And PostgreSQL's own row security documentation notes that superusers, roles with BYPASSRLS, and the table owner skip row security entirely unless you add force row level security — which is why the service_role key must never leave your server.

A laptop with an open terminal beside a printed checklist of database tables with several lines ticked in red pen
Table-by-table review is slow, boring, and the only method that actually finds the gaps.

The six-step hardening sequence for a Next.js and Supabase build

Do these in order. Steps that look optional are the ones that make the later steps possible.

Step 1: enable, force, and take back the blanket grants

alter table public.documents enable row level security;

alter table public.documents force row level security;

 

-- RLS is layered on grants, so remove what the generator handed out

revoke all on public.documents from anon, authenticated;

grant select, insert, update, delete on public.documents to authenticated;

force row level security is the line most guides skip. It makes the policy apply to the table owner too, so a migration script or an admin connection cannot quietly read everything by accident.

Step 2: make the tenancy model explicit in a table

create table public.memberships (

user_id uuid not null references auth.users on delete cascade,

tenant_id uuid not null references public.tenants on delete cascade,

role text not null default 'member',

primary key (user_id, tenant_id)

);

 

-- security definer breaks the recursion a policy on memberships would cause

create or replace function public.current_tenant_ids()

returns setof uuid

language sql stable security definer set search_path = ''

as $$

select tenant_id from public.memberships where user_id = (select auth.uid());

$$;

 

revoke all on function public.current_tenant_ids() from public;

grant execute on function public.current_tenant_ids() to authenticated;

If you have ever seen infinite recursion detected in policy for relation, this function is the fix. A policy on memberships that queries memberships re-enters itself forever; a security definer function steps outside the policy to answer the question once. Setting search_path = '' on it is not decoration — without it, a definer function can be tricked into resolving a table name to somewhere you did not intend.

A healthcare scheduling product arrived with us carrying 41 tables and 41 policies, one per table, every one of them for all using (true). The team had assumed the count was the coverage. Rewriting the policies took four days, and three of those days went into working out what the ownership model was supposed to be, because nobody had ever written it down anywhere — not in code, not in a document, not on a whiteboard. Policies are cheap once tenancy is explicit. That is the whole reason step two comes before step three.

Step 3: write one policy per command, never one for all

create policy "members read documents"

on public.documents for select to authenticated

using ( tenant_id in (select public.current_tenant_ids()) );

 

create policy "members create documents"

on public.documents for insert to authenticated

with check ( tenant_id in (select public.current_tenant_ids())

and created_by = (select auth.uid()) );

 

-- both clauses: old row must be yours, new row must stay yours

create policy "members edit documents"

on public.documents for update to authenticated

using ( tenant_id in (select public.current_tenant_ids()) )

with check ( tenant_id in (select public.current_tenant_ids()) );

 

create policy "authors delete documents"

on public.documents for delete to authenticated

using ( created_by = (select auth.uid()) );

The most expensive line you will not write. An UPDATE policy with using but no with check lets a signed-in user rewrite a row's tenant_id and move your customer's data into their own workspace. Postgres tests using against the row as it was and with check against the row as it will be, so you need both.

Step 4: make the policies fast before they make the app slow

create index documents_tenant_id_idx on public.documents (tenant_id);

create index memberships_user_id_idx on public.memberships (user_id);

 

-- confirm the policy uses the index instead of scanning the table

explain (analyze, buffers)

select id, title from public.documents order by updated_at desc limit 50;

Two habits carry almost all of the performance win here. Wrap every auth helper in a subquery — (select auth.uid()) rather than auth.uid() — so Postgres evaluates it once as an InitPlan instead of once per row, as the Supabase RLS guide recommends. Then index every column a policy filters on. A policy is a WHERE clause the planner cannot see coming; an unindexed one turns each page load into a sequential scan.

We once traced a dashboard that everyone had written off as "Supabase being slow" to a single missing index on a tenant column. The policy was correct. The plan was a full table scan on every request.

Step 5: seal the Next.js edges

// WRONG - bypasses every policy you just wrote

const db = createClient(url, process.env.SUPABASE_SERVICE_ROLE_KEY)

 

// RIGHT - the user's JWT reaches Postgres, policies decide

const db = createServerClient(url, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, { cookies })

Route handlers and Server Actions are where service-role keys quietly creep in, usually because a policy blocked something during development and the fastest unblock was to escalate. Use Supabase's SSR client for Next.js everywhere a user is present, and keep service-role usage to a short list of jobs you can name out loud: webhooks, cron, admin backfills.

Two more surfaces get forgotten. Views run with the view owner's rights unless you say otherwise, and PostgreSQL 15 added the security_invoker option to CREATE VIEW to fix that — set it on, or your carefully written policy is invisible to anything reading through the view. Storage is the other: buckets need their own policies on storage.objects, and the Supabase access control guide shows the folder-prefix pattern most tenant setups need.

Step 6: test the policies as a hostile user

begin;

select set_config('request.jwt.claims',

'{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true);

set local role authenticated;

 

-- expect: only this user's tenant

select count(*) from public.documents;

-- expect: 0 rows affected, not a silent success

update public.documents

set tenant_id = '22222222-2222-2222-2222-222222222222';

rollback;

Run that as a pgTAP test in CI, not as a one-off in a console. Supabase documents the setup in its database testing guide, and a policy suite is one of the few test suites that pays for itself the first time someone regenerates a migration.

AreaTypical generated defaultWhat survives a review
Row securityEnabled on some tablesEnabled and forced on every table
Policy scopefor all, role omittedFour policies, each to authenticated
Tenant sourceuser_metadata claimMemberships table via a definer function
Updatesusing onlyusing plus with check
PerformanceBare auth.uid(), no index(select auth.uid()) plus an index per filtered column
Service roleUsed to unblock developmentWebhooks, cron and backfills only
A policy that returns true is not a policy. It is a comment the database happens to accept.

How do you audit an AI-generated Supabase project?

Run four checks against the system catalogs, in this order: find tables where row security is off, find policies whose expression is literally true, find policies that apply to the anon role, and find UPDATE policies with no WITH CHECK clause. Those four queries locate the majority of holes in a generated schema inside an hour.

-- 1. tables with row security switched off

select tablename from pg_tables

where schemaname = 'public' and rowsecurity = false;

 

-- 2 and 3. open expressions, and anything reachable by anon

select tablename, policyname, roles, cmd, qual, with_check

from pg_policies where schemaname = 'public'

and (qual = 'true' or with_check = 'true' or 'anon' = any(roles));

 

-- 4. updates that can rewrite a row into another tenant

select tablename, policyname from pg_policies

where cmd in ('UPDATE', 'ALL') and with_check is null;

Then let the platform help. The Supabase dashboard flags unprotected tables with an "Unrestricted" badge and its Security Advisor repeats several of these checks automatically, and the going-to-production checklist is worth reading line by line before launch rather than after.

The part the tutorials leave out

AI builders are genuinely good at this work now. They produce a schema, an auth flow and a working UI faster than any team I have hired, and I would not go back. But a model optimises for the app running, and access control is the one area where a working app and a correct app look identical from the outside. There is no error message for data being too visible.

That gap is not a reason to stop generating code. It is a reason to change what review means. On the projects my team at IndiaNIC picks up mid-flight, the hardening work is rarely more than a week: map the tenancy, rewrite the policies command by command, index what the policies filter, move the service-role calls behind a server boundary, and lock the whole thing down with pgTAP so the next regenerated migration cannot undo it. The scaling startups that get burned are not the ones who used AI. They are the ones who never had a second pair of eyes on the layer the AI could not check itself.

Peter Drucker's line fits here better than any security aphorism: what gets measured gets managed. Measure your policies.

Your next 24 hours

Open the SQL editor on your production project tonight and run the four catalog queries above. Paste the results into a document, unedited. If the first query returns even one table name, or the second returns a single row with qual = 'true', you have found today's work — and you can close the worst of it before tomorrow's standup with a handful of alter table and create policy statements from this guide. Do the invoices table first, whatever it is called in your schema.

If you would rather have someone walk your schema with you, that is exactly the kind of review my engineering team does for scaling startups. Either way, run the queries. The report takes four minutes; not knowing costs considerably more.

Frequently asked questions

Does Supabase enable Row Level Security by default?

Tables created through the Supabase dashboard's table editor have RLS enabled by default, but tables created by running create table in the SQL editor or inside a migration file do not. AI builders normally generate migrations, so verify pg_tables.rowsecurity for every table instead of trusting the default.

Is it safe to expose the Supabase anon key in a Next.js client bundle?

Yes. The anon key is designed to be public and Supabase ships it in NEXT_PUBLIC_ environment variables. It is safe only while your policies are correct, because it grants whatever the anon role can reach. The service_role key is the opposite: it bypasses every policy and must stay server-side.

Why does my Supabase policy return "infinite recursion detected in policy"?

The policy queries the same table it protects, so evaluating it triggers itself again. It happens most often on a memberships or team_members table. Fix it by moving the lookup into a security definer function marked stable with set search_path = '', then calling that function from the policy.