Home / Guides / Supabase RLS for AI agents
Stop AI agents creating Supabase tables without RLS
AI coding agents write a clean create table, add a foreign key, and move on. They skip row-level security because the query works from the SQL editor and the type generator is happy. That table is then reachable through the Data API by any role that has a grant. The Supabase API security guide is blunt: tables exposed without RLS can be accessed by any role with matching grants.
This page is the RLS rule itself: enable it in the same migration, write policies, and never turn it off to “make it work”. For the rest of a Next.js + Supabase Cursor setup (auth helpers, env, Stripe), use Cursor rules for Next.js + Supabase.
Why agents leave RLS off
Tables created in the Supabase Dashboard have RLS enabled by default. Tables created in a migration, the SQL Editor, or an ORM dump do not. Agents almost always use SQL. Postgres also starts a new table with RLS disabled, so “it works” is the default until someone adds enable row level security.
- The happy path hides it. The agent uses the service-role key in a seed script. That key bypasses RLS, so every query succeeds.
- The error looks like a bug. Once RLS is on and there is no policy, the Data API returns no rows. Agents “fix” that by disabling RLS instead of writing a policy.
- Grants are leftover.
anonandauthenticatedoften still haveselect(or worse). RLS off plus a grant is a public table.
Official reminder: enable RLS on every table and view the Data API exposes, then write policies. See Row Level Security.
The rule: same migration, or the table does not ship
Put this in AGENTS.md and in a path-scoped Cursor rule on supabase/**. The agent should refuse to commit a create table that does not also enable RLS and define policies.
--- description: Migrations and row-level security globs: supabase/** alwaysApply: false --- - For each newly created table, enable row level security and define its policies in the same migration. - Policies use (select auth.uid()) for performance; separate policies per command (select/insert/update/delete). - Add indexes on every column used in a policy or foreign key. - Never disable RLS to 'make it work'. If a query fails, fix the policy and explain it. - Regenerated types after schema changes go in the project's existing types file.
The same bullets live as a free file on GitHub: nextjs-supabase-rls.mdc. For Windsurf, drop the front matter into .windsurf/rules/; see Windsurf rules setup.
What the migration should look like
Enable RLS, tighten grants, then add one policy per command. Separate policies keep using (read/filter) and with check (write) obvious, and they stay readable when an agent edits only insert later.
create table public.invoices ( id uuid primary key default gen_random_uuid(), owner_id uuid not null references auth.users (id), amount_cents integer not null, created_at timestamptz not null default now() ); create index invoices_owner_id_idx on public.invoices (owner_id); alter table public.invoices enable row level security; revoke all on table public.invoices from anon, authenticated; grant select, insert, update, delete on table public.invoices to authenticated; create policy "invoices_select_own" on public.invoices for select to authenticated using ((select auth.uid()) = owner_id); create policy "invoices_insert_own" on public.invoices for insert to authenticated with check ((select auth.uid()) = owner_id); create policy "invoices_update_own" on public.invoices for update to authenticated using ((select auth.uid()) = owner_id) with check ((select auth.uid()) = owner_id); create policy "invoices_delete_own" on public.invoices for delete to authenticated using ((select auth.uid()) = owner_id);
Wrapping auth.uid() in select is the documented initPlan pattern: Postgres evaluates the function once per statement instead of once per row. Only use it when the result does not depend on the current row. Index the columns the policy compares, here owner_id.
With RLS on and no policy, publishable-key requests see nothing. That empty result is safer than an open table. If the agent’s query “fails”, the policy is missing or wrong. Do not alter table … disable row level security.
What to tell the agent about clients
- Browser and user-scoped server code use the publishable (anon) key plus a user JWT. RLS is the access control.
- The service-role key bypasses RLS. It belongs on the server only. Never prefix it with
NEXT_PUBLIC_. If a job must write across users, keep that path tiny and audited. - Do not use RLS as the only filter. Still add
.eq('owner_id', userId)in the query. Policies are security; explicit filters keep the intent visible and the plan cheaper. - Views need the same treatment if they are exposed through the Data API.
Prove it with a database test
A rule without a test is a comment. Supabase can run pgTAP files with supabase test db. Ask the agent for a case that inserts as user A and fails to read as user B.
begin; select plan(2); -- user A can read their row; user B cannot. -- Use supabase test helpers / auth.uid() stubs your project already has. select ok( (select count(*) from public.invoices) >= 0, 'suite loads; replace with two auth.uid() cases' ); select ok( true, 'other-user select returns 0 rows under authenticated' ); select * from finish(); rollback;
Replace the stubs with your project’s auth helpers. The point of the rule is: no new table without an other-user case. If you do not want to teach the agent pgTAP, require a failing-then-passing integration test that uses two sessions instead.
AGENTS.md lines that actually get followed
Keep the always-on file short. The migration glob rule above does the path-scoped work. These four lines belong in the shared AGENTS.md:
## Database - Every new table enables RLS and defines per-command policies in the same migration. - Policies use (select auth.uid()). Never disable RLS to make a query work. - Service-role key stays on the server. Never NEXT_PUBLIC_ it. - After schema changes, regenerate types into the existing types file.
Put the file at the repo root so Cursor, Codex and Windsurf all see it. Claude Code picks it up with @AGENTS.md in CLAUDE.md. Which file each tool reads: AGENTS.md vs CLAUDE.md vs Cursor rules.
If the same agent is about to grant paid access from a Stripe redirect, that is a different hole. Pair this page with Stripe webhooks for AI agents.
FAQ
Do new Supabase tables have RLS enabled?
Tables created in the Supabase Dashboard have RLS enabled by default. Tables created in the SQL Editor, migrations, or another tool do not. Enable RLS explicitly in the same migration that creates the table.
What happens if RLS is on but there are no policies?
Requests that use a publishable key see no rows. That is safer than leaving RLS off. Add per-command policies for the roles that should have access.
Why wrap auth.uid() in a select?
Using (select auth.uid()) lets Postgres cache the value once per statement (an initPlan) instead of calling the function on every row. Only do this when the result does not depend on the current row.
Does the service role bypass RLS?
Yes. The service-role key bypasses row-level security. Keep it on the server only. Never prefix it with NEXT_PUBLIC_ or ship it to the browser.
Should I disable RLS to debug a failing query?
No. If a query fails, fix the policy and explain it. Disabling RLS to make it work is how production tables end up readable by anyone with the anon key.
Building on Next.js + Supabase?
The Supabase + Next.js Agent Kit (DRW-06, $19) puts this RLS rule into a full setup: a migration rule that requires RLS, revoked default grants and one policy per command in the same file, a Claude Code hook that blocks a new table without them, a read-only migration-reviewer subagent, plus @supabase/ssr auth, typed queries, secret-key and Edge Function rules.
Need it fixed today? get a fixed-price Supabase fix.
Written against documented conventions as of October 2026; not yet run against a reference project. 14-day refund.
Want the whole drawer?
One-File Agent Rules ($9) is one universal AGENTS.md with a plan gate, verify-before-done, and a Next.js + Supabase stack module that includes the RLS rule. The Vibe Starter Rules Pack ($19) adds a fill-in AGENTS.md + CLAUDE.md, 8 scoped Cursor rules (auth, RLS, Stripe webhooks, env, tests, UI), 12 prompts and launch checklists.
Or get the free Starter AGENTS.md by email. One-time purchase, instant download. Packs are written against documented conventions; we have not benchmarked them against a reference project.
Launch week: 30% off any kit with code LAUNCH at Stripe checkout, through Oct 28, 2026. 14-day refund.