Your first table: enable RLS, write one policy, query it

A table created with SQL has RLS off; enable it, add a SELECT policy with (select auth.uid()) = owner, and remember INSERT needs its own policy plus .select().

Getting Started

· Chapter

11

·

3

min read

The answer. Studio's table editor enables row-level security by default; raw SQL does not, and a table with RLS off is readable by anyone holding the publishable key. So the first migration for any table is three statements: create it, enable RLS, write the policy that opens exactly the rows the caller may see. A policy is a boolean expression evaluated per row as the calling role; auth.uid() is the caller's user id from the JWT (null for anonymous callers and in the SQL editor). Two things surprise everyone on day one: a table with RLS on and no matching policy returns an empty array with no error, and an INSERT policy does not let you read the row you just wrote, so the client's .insert().select().single() needs a SELECT policy as well.

The pattern.

create table public.notes (
  id bigint generated always as identity primary key,
  owner uuid not null default auth.uid() references auth.users (id) on delete cascade,
  body text not null check (length(body) > 0),
  created_at timestamptz not null default now()
);
alter table public.notes enable row level security;
alter table public.notes force row level security;
create policy "owner reads" on public.notes for select to authenticated using ((select auth.uid()) = owner);
create policy "owner writes" on public.notes for insert to authenticated with check ((select auth.uid()) = owner);
const { data, error } = await supabase.from("notes").insert({ body: "hello" }).select().single();  // needs both policies

Watch out.

  • to authenticated is not decoration: it stops Postgres from evaluating the policy for roles it can never match, and it keeps anonymous callers out even when the expression would fail anyway.
  • Write (select auth.uid()), not auth.uid(); the sub-select is evaluated once per statement instead of once per row.
  • Test as a user, not as postgres: the SQL editor runs as the table owner, where auth.uid() is null and, without force, RLS is skipped entirely.

Related: rls-test-set-local-role-and-jwt-claims · update-policies-have-two-halves-using-and-with-check · rls-performance-wrap-auth-calls-index-the-policy-column