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.

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
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
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
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
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
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
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.
| 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.
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.
