Row-Level Security

Your project is a PostgreSQL database exposed to the public internet. Row-Level Security (RLS) is what decides, row by row, who is allowed to read and write it, so your tables stay safe even though the REST, storage, and realtime APIs face the world.

How it works

Every request to the data API runs inside your database as a database role, carrying the caller's identity. A policy is a SQL rule attached to a table that says which rows that role may see or change. Postgres applies your policies automatically on every select, insert, update, and delete; there is no application layer to bypass and nothing to remember to call.

RLS is opt-in per table but deny-by-default once enabled. Turn it on, and until you add a policy no one (except the service role, below) can touch the table through the API:

alter table public.profiles enable row level security;
-- With RLS on and no policy yet, every API read returns zero rows and
-- every write is rejected: "new row violates row-level security policy".
Enable RLS on every table you expose through the API. A table with RLS switched off is readable and writable by anyone holding your publishable key, which ships in client code. The dashboard flags such tables, and the built-in advisors warn about them.

The three roles

Which role a request runs as is decided by the key or token it presents. There are three, and your policies are written in terms of them:

RolePresented byRLS
anon The publishable key (kbp_…): an unauthenticated visitor. Enforced. auth.uid() is NULL.
authenticated An end user's access token, after they sign in through the auth API. Enforced. auth.uid() is that user's id.
service_role The secret key (kbs_…): your own backend. Bypassed entirely. Never expose this key to a browser.

Inside a policy you read the caller's identity through four helper functions, available in every project:

FunctionReturns
auth.uid()The signed-in user's id (uuid), or NULL when not authenticated.
auth.role()The role name as text: 'anon', 'authenticated', or 'service_role'.
auth.jwt()The full access-token claims as jsonb: read your own custom claims from here.
auth.email()The signed-in user's e-mail, when present in the token.

Write policies against auth.*, not to <role>

This is the one thing that trips up people bringing policies from another platform. Postgres lets you target a policy at a grantee role with a to <role> clause. Do not use it with the names anon, authenticated, or service_role. Those names exist only as claims inside the token; the physical database roles behind them are private, per-project identifiers, so a policy written…

-- ✗ Does NOT work on Kethosbase: role "authenticated" does not exist here,
-- so this policy is created against a role that never runs, and never matches.
create policy "p" on public.notes for select
  to authenticated
  using ( true );

…silently applies to no one. Put the identity check in the using / with check expression instead, where the auth.* helpers do the work:

-- ✓ Works. No "to" clause: the policy is evaluated for every request, and
-- auth.role() distinguishes who is calling.
create policy "notes readable when signed in" on public.notes for select
  using ( auth.role() = 'authenticated' );
Omitting the to clause means every request evaluates the policy, including the publishable (anon) key, for which auth.uid() is NULL. That is exactly what you want: the expression, not the role targeting, decides access.

Recipes

Each user owns their rows

The most common shape. Store the owner's id in a column and compare it to auth.uid(). Default the column so a client can never write someone else's id:

create table public.notes (
  id       bigint generated always as identity primary key,
  owner    uuid not null default auth.uid(),
  body     text not null,
  created  timestamptz not null default now()
);
alter table public.notes enable row level security;

create policy "read own"   on public.notes for select
  using ( owner = auth.uid() );

create policy "insert own" on public.notes for insert
  with check ( owner = auth.uid() );

create policy "update own" on public.notes for update
  using      ( owner = auth.uid() )
  with check ( owner = auth.uid() );

create policy "delete own" on public.notes for delete
  using ( owner = auth.uid() );
using filters the rows a statement can see (reads, and the rows an update/delete may target); with check validates the rows a statement tries to write. An insert needs only with check; an update usually needs both. Write one policy per operation; a policy for select does not grant insert.

Public read, owner-only write

A blog, say. Anyone may read, only the author may change:

create policy "posts are public"      on public.posts for select
  using ( true );

create policy "authors write posts"   on public.posts for insert
  with check ( author = auth.uid() );

create policy "authors edit own"      on public.posts for update
  using ( author = auth.uid() ) with check ( author = auth.uid() );

Team / multi-tenant scoping

When rows belong to an organization rather than a person, join to a membership table (or read a claim you put in the token). A membership check keeps the rule in one place:

create policy "members see their org rows" on public.invoices for select
  using (
    org_id in (
      select org_id from public.memberships
      where user_id = auth.uid()
    )
  );

If you mint your own claims (for example an org_id baked into the token), read them straight from auth.jwt() to skip the join:

using ( org_id = (auth.jwt() -> 'app_metadata' ->> 'org_id')::uuid )

Server-only tables

To make a table reachable only from your backend, enable RLS and write no policy at all. The anon and authenticated roles are denied everything; the secret key (service_role) bypasses RLS and still has full access.

Storage follows the same rules

File access is governed by RLS on storage.objects, exactly like your own tables; each upload records the uploader in owner_sub, and you gate access with owner_sub = auth.uid(). See Storage → Access control for the bucket recipes.

Testing your policies

The service role bypasses RLS, so a query that works from your backend proves nothing about what a user can see. Test as the actual role:

A policy is only as good as the data behind it. Default owner columns to auth.uid() so a client cannot forge ownership, and remember that a permissive select policy also governs what Realtime will stream to a subscriber.