DEV Community

AgentKit Drawer
AgentKit Drawer

Posted on Originally published at agentkitdrawer.com

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 post 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. anon and authenticated often still have select (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.
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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


The free Starter AGENTS.md is a one-click download, no email needed. The full setup (migration rule, a Claude Code hook that blocks a table without RLS, a migration-reviewer subagent) is the Supabase + Next.js Agent Kit.


DRW-06 $19, LAUNCH code ends Wed

Supabase + Next.js Agent Kit: rules, a read-only migration reviewer and a hook that blocks new tables without RLS. LAUNCH (30% off) is pre-filled at Stripe checkout until Wed Oct 28, 6:20 PM CT.

Top comments (0)