· Vibe coding ·Supabase ·Security ·Tutorial

Supabase RLS for vibe coders — the 5-minute fix

Row-Level Security is the difference between a private database and a public one. Here's how to set it up correctly the first time, and how to test it actually works.

The single most common bug in AI-built apps is a Supabase table with the wrong Row-Level Security configuration. Either RLS is off entirely (the table is public), or it’s on with a permissive policy (the table is effectively public), or it’s on with a correct-looking policy that doesn’t actually do what it claims.

This is the post to read once and apply forever. Step by step: how to set up RLS correctly for the patterns vibe-coded apps actually use, how to test that your policy does what you think it does, and the three traps the AI keeps falling into.

What RLS is, briefly

Supabase puts a Postgres database behind a public API. By default, your frontend JS calls supabase.from('table').select('*') and that query goes through PostgREST, against your database, with whatever permissions the calling user has.

The “whatever permissions” part is what RLS controls. With RLS off on a table, anyone with the anon key (which is in every Supabase app’s frontend bundle by design) can read every row. With RLS on, queries are filtered through policies you write — and anything not allowed by a policy is denied.

The default state of a new Supabase table is RLS off. The default state of a fresh table is “fully public to anyone with your project URL.” Vibe-coding tools rarely fix this without being asked specifically.

The 5-minute fix

Step 1: turn RLS on for every table

In Supabase dashboard:

  1. Go to Database → Tables.
  2. For each table, click the table, click Edit table, scroll down to Row Level Security, check the box.
  3. Save.

Or via SQL, one-liner per table:

ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
-- repeat for every table

After this, every table denies all access by default. Your app will break. That’s expected — it’s how you find out which tables need policies. Add policies to restore the access your app actually needs.

Step 2: write the right policy for each table

The patterns vibe-coded apps use are almost always one of these three. Pick the one that matches and use the SQL exactly.

Pattern A: “users can only see and edit their own rows”

This is for tables like profiles, user_settings, preferences — a row per user, owned by that user. Assume the table has a user_id column referencing auth.users(id).

-- SELECT
create policy "Users can read their own row"
on public.profiles for select
to authenticated
using (auth.uid() = user_id);

-- UPDATE
create policy "Users can update their own row"
on public.profiles for update
to authenticated
using (auth.uid() = user_id)
with check (auth.uid() = user_id);

-- INSERT
create policy "Users can insert their own row"
on public.profiles for insert
to authenticated
with check (auth.uid() = user_id);

-- DELETE (only if you actually want users to be able to delete)
create policy "Users can delete their own row"
on public.profiles for delete
to authenticated
using (auth.uid() = user_id);

Four policies. Each operation gets its own. Don’t combine.

Pattern B: “everyone can read, only the owner can write”

For things like blog posts, public products, public chats. There’s an author_id referencing the user.

-- Anyone can read (including unauthenticated visitors)
create policy "Anyone can read"
on public.posts for select
to anon, authenticated
using (true);

-- Only the author can update / insert / delete their own row
create policy "Owner can insert their own"
on public.posts for insert
to authenticated
with check (auth.uid() = author_id);

create policy "Owner can update their own"
on public.posts for update
to authenticated
using (auth.uid() = author_id)
with check (auth.uid() = author_id);

create policy "Owner can delete their own"
on public.posts for delete
to authenticated
using (auth.uid() = author_id);

Pattern C: “admins see everything, users see their own”

For tables where you have an admin role that needs full access alongside regular user access. Assumes you’ve stored the role in JWT user metadata at auth.jwt() ->> 'role'.

create policy "Users see their own, admins see all"
on public.tickets for select
to authenticated
using (
  auth.uid() = user_id
  or auth.jwt() ->> 'role' = 'admin'
);

-- Same shape for UPDATE, with both using and with check
create policy "Users update their own, admins update all"
on public.tickets for update
to authenticated
using (
  auth.uid() = user_id
  or auth.jwt() ->> 'role' = 'admin'
)
with check (
  auth.uid() = user_id
  or auth.jwt() ->> 'role' = 'admin'
);

The auth.jwt() ->> 'role' reads from custom claims you add when issuing tokens. If you’re using Supabase Auth and haven’t set up custom claims, you’ll need to do that first — app_metadata.role is the standard place to put it.

Step 3: test the policy actually works

This is the step the AI never does. Even when it writes a correct-looking policy, you need to verify the policy is actually blocking unauthorized access.

The test is a three-line script you run from anywhere with the Supabase JS client:

import { createClient } from '@supabase/supabase-js'

// Anon key — same as in your app's frontend bundle
const supabase = createClient(
  'https://YOUR_PROJECT.supabase.co',
  'YOUR_ANON_KEY'
)

// Try to read the table with NO login
const { data, error } = await supabase
  .from('your_table')
  .select('*')

console.log('rows returned:', data?.length)
console.log('error:', error)

What you want to see depends on the pattern:

  • Pattern A (own-row-only): data is null or empty, error is undefined. Anonymous users can’t see anything.
  • Pattern B (public read): data returns all rows. That’s intentional. The point is that write operations should fail, which is the next test.
  • Pattern C (admin or own): same as A for anon. Then sign in as a regular user — they should only see their rows.

To test the write operations:

const { error } = await supabase
  .from('your_table')
  .insert({ name: 'test', user_id: 'some-other-uuid' })

// Should error with row-level security violation
console.log('insert error:', error)

If the insert succeeds (no error), your policy is wrong. An anonymous user just inserted a row claiming to be someone else.

Step 4: write the same test in your CI

The most common way RLS regresses is that a later migration disables it, or a “for debugging” policy gets added and never removed, or a refactor changes a column name and the policy no longer matches. If the test from step 3 lives in your CI, you catch this when it happens, not three months later when an attacker does.

The three traps the AI keeps falling into

Trap 1: using (true)

When you ask an AI to “add a policy that lets authenticated users access this table,” the policy you sometimes get is using (true) — which means “anyone authenticated, including a brand-new anonymous signup, can read every row of this table.”

This is technically RLS-enabled. It is functionally identical to RLS-off.

Fix: if you see using (true) on a table that shouldn’t be world-readable, replace it with the right policy from above. If the AI wrote it, it’ll probably defend the choice — push back. auth.uid() = something is the pattern you want.

Trap 2: forgetting one of SELECT / INSERT / UPDATE / DELETE

Common bug: the AI writes a SELECT policy and stops. You can read your own rows. You can also insert rows for other users, because there’s no INSERT policy and the default-deny only applies to operations that have policies defined.

Wait, that’s wrong. Default-deny means: with RLS on, an operation without a matching policy is denied. So missing an INSERT policy means INSERTs are denied. Which seems safe.

The bug isn’t the missing policy. It’s the partial policy — the AI writes one INSERT policy that happens to be too permissive, and your check for “is RLS enabled” gives a green light.

Fix: for any user-facing table, you need policies for SELECT, INSERT, UPDATE, DELETE separately, each scoped to the right rows. Don’t trust a single policy unless you’ve verified it covers everything you need.

Trap 3: confusing using and with check

using controls which existing rows the policy applies to (for SELECT, UPDATE, DELETE). with check controls which new or modified rows are allowed (for INSERT, UPDATE).

A correct UPDATE policy needs both: using to say which rows the user can touch, with check to say what the row is allowed to look like after the update.

AI tools often write only the using clause. The result: a user can update their own row to change the user_id to someone else’s, taking ownership of someone else’s data. The using check passed because they did own the row when they started.

Fix: for UPDATE policies, always write both using and with check, and make them the same condition unless you have a specific reason for them to differ.

What this doesn’t fix

RLS is one layer. It doesn’t fix:

  • Secrets leaked in the frontend (see the pillar guide).
  • Service role key in the bundle (which bypasses RLS entirely).
  • Authorization in custom API routes you wrote outside Supabase.
  • Edge Functions that don’t check the caller.

If RLS is right but the service role key shipped to the browser, RLS doesn’t matter — the key has full bypass access. RLS is necessary, not sufficient.

The shortcut

If you don’t want to read the SQL above and check policies one at a time, Patchable does it for you. We attempt anonymous reads against your live Supabase project, see what comes back, and tell you which tables are exposed — including which RLS policies look right but aren’t. Each finding ships with the correct policy SQL as a paste-ready prompt for your AI tool. Free to scan. You only pay if there’s something to fix.

RLS is the difference between a private database and a public one. Find out which yours is. Run the scan.

Have us scan your app.

We're taking on a small number of scans right now and running each one personally. Drop your app below — we'll take a look and reach out if it's a fit, usually within a day.

Built by Ahmet & Leonardo in Tallinn. We answer every email to hi@patchable.dev.