CheckVibeCodeCheckVibeCode
All posts
SecurityJune 18, 20267 min read

Row Level Security, explained for people who didn't write the schema

C

CheckVibeCode Team

Product & Security Research

Row Level Security sounds like infrastructure jargon, but the idea underneath it is simple: a rule, attached to a table, that decides which rows a given request is allowed to see or change. Without one, a table is either fully open or fully closed to the client — and most generated schemas default to open, because open is what makes the demo work on the first try.

The one-sentence mental model

RLS is a WHERE clause the database adds automatically, based on who's asking. A policy like "users can select rows where user_id equals their own id" means every query, from every client, gets that filter applied — even a query the client wasn't supposed to send in the first place.

-- The policy most tables actually need, in full
alter table public.orders enable row level security;

create policy "users can view their own orders"
on public.orders for select
using (auth.uid() = user_id);

create policy "users can insert their own orders"
on public.orders for insert
with check (auth.uid() = user_id);

That's the whole pattern for a typical user-owned table: select is gated with using(), insert and update are gated with with check() (Postgres checks using() against the row as it exists, and with check() against the row as it would exist after the write — so update policies usually need both), and every clause anchors to auth.uid(), the one value a client can never forge.

Why it matters more for vibe-coded apps specifically

Traditional backends put a server between the browser and the database, so access control lives in application code the client never sees. Tools like Supabase let the browser talk to the database directly — which is what makes them fast to build with, and exactly why the database itself has to enforce the rules. There's no server layer quietly protecting you.

The policy almost everyone forgets

Enabling RLS on a table with zero policies makes it fully inaccessible — safe, but broken, so it gets noticed and fixed fast. The dangerous middle state is a table with RLS enabled and a policy that's broader than intended: a SELECT policy with USING (true), or an UPDATE policy that checks the row exists but never checks who owns it. Those pass every manual click-test because the developer testing them is always looking at their own data.

-- Looks protected, isn't:
create policy "users can update orders"
on public.orders for update
using (true);

-- Actually scoped to the owner:
create policy "users can update their own orders"
on public.orders for update
using (auth.uid() = user_id)
with check (auth.uid() = user_id);

Both versions let the account that created the row update it — which is all a single-user test will ever show you. The difference only shows up when a second account tries the same request against someone else's row_id, which is exactly why testing as yourself is the wrong test.

One key can turn all of this off

Supabase's service-role key bypasses RLS entirely, by design — it's meant for trusted server code, like a webhook handler or a cron job. It's also just an environment variable, which means it's one accidental client-side import away from being the same kind of exposed secret as an API key, except this one doesn't just leak data — it deletes it too, policies or not. It should never leave your server, and it should never be the key your client-side Supabase instance is initialized with.

  • Enable RLS on every table that holds user data — there is almost never a reason not to.
  • Write policies per-operation: select, insert, update, and delete can each have different rules.
  • Scope policies to auth.uid(), not to a value the client can send in the request body.
  • Use with check() alongside using() on insert and update policies — using() alone doesn't validate the new row.
  • Test as a second, unprivileged user — not as the account that created the data.
  • Keep the service-role key server-side only, and never in a NEXT_PUBLIC_ or VITE_ variable.
  • Re-check policies after every schema change, not just at launch.

If you connect your project to a scanner that can read your schema, this is a five-second check instead of a five-minute one — it flags exactly which tables have RLS off, and which policies are wider than they should be.

See where your own site stands.

Run a free scan and get the same checks this post talks about, applied to your app.