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.