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".
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:
| Role | Presented by | RLS |
|---|---|---|
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:
| Function | Returns |
|---|---|
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' );
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:
- In the dashboard. The Table Editor can impersonate a role or a specific user (the view as selector) and re-run the grid under their policies, so you see precisely the rows that user would.
- Over Direct SQL. Simulate a
request by switching role and publishing claims inside a transaction:
begin; select set_config('request.jwt.claims', '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}', true); set local role authenticated; -- use the role your app connects as select * from public.notes; -- only that user's rows come back rollback; - From the API. Call with the publishable key (anon) and with a signed-in user's token, and confirm each sees only what it should.
auth.uid() so a client cannot forge ownership, and
remember that a permissive select policy also governs what
Realtime will stream to a subscriber.