Row level security is a database feature that attaches rules to a table so every query returns only the rows the caller is allowed to see. The database filters, not your code. This page covers the Postgres version: enable it on a table, then write a policy, because a table with row security on and no policy returns nothing.
See which of your tables a stranger can read, free
What is row level security? A WHERE clause the database adds for you
A row security policy is a boolean expression Postgres attaches to a table. Once it exists, select * from documents behaves as though you had written select * from documents where owner = current_user, and nothing the caller does strips that clause. Not a crafted query string, not a filter your AI assistant forgot, not a raw HTTP call to your data API.
Policies are per table and per command: read your own rows, update none of them. Postgres has had this since 9.5, released January 2016.
On reads, rows that fail the check just do not come back. Postgres: "Typically, such rows are silently suppressed; no error is reported (but see Table 300 for exceptions)." A shorter list, and nothing to notice. On writes, the same rule throws new row violates row-level security policy for table "documents". Missing data is easy to miss. A crash is not.
Is row level security on by default?
In plain Postgres, no. The docs are blunt: "By default, tables do not have any policies, so that if a user has access privileges to a table according to the SQL privilege system, all rows within it are equally available for querying or updating."
On Supabase the answer is split, and this is the part most write-ups get wrong. Supabase's docs: "RLS is enabled by default on tables created with the Table Editor in the dashboard." For the other path they say "If you create one in raw SQL or with the SQL editor, remember to enable RLS yourself and grant only the permissions each Postgres role needs."
That default only flips the switch, though. The Table Editor turns row security on and writes no policy, which leaves the table deny-all: closed to strangers and closed to your own app until you add one. A table an agent creates in a migration gets neither the switch nor the policy.
You meet this because Supabase serves the public schema over HTTP, and the publishable key that reaches that endpoint is meant to be public. RLS is the only thing behind it. Supabase's own linter rates a table with RLS off in an exposed schema as an ERROR, lint 0013, titled "RLS Disabled in Public". Its wording: "anyone with the project's URL can CREATE/READ/UPDATE/DELETE (CRUD) rows in the impacted table."
What happens the moment you turn it on
Nothing is allowed. That is the design.
-- Nothing is permitted after this until a policy says so.
alter table public.documents enable row level security;
Postgres: "If no policy exists for the table, a default-deny policy is used, meaning that no rows are visible or can be modified." That is the most common surprise here, phrased in Supabase's discussions as "Why is my select returning an empty data array and I have data in the table?" The table is fine. The policy list is empty.
A second statement almost nobody runs closes a real gap:
-- Also apply policies to the table's owner. Otherwise the owner sails past them.
alter table public.documents force row level security;
USING vs WITH CHECK
These two clauses are not interchangeable, and mixing them up is how a policy that looks right lets a user rewrite someone else's row. USING decides which existing rows a command may see or touch. WITH CHECK validates the row you are writing, and errors when it fails.
-- <your_app_role> is a placeholder. Substitute the role your app connects as;
-- on Supabase that role is `authenticated`, and a runnable version is below.
-- Read: which existing rows are visible.
create policy documents_select_own
on public.documents
for select
to <your_app_role>
using (owner = current_user);
-- Insert: what a new row may contain. INSERT takes WITH CHECK only.
create policy documents_insert_own
on public.documents
for insert
to <your_app_role>
with check (owner = current_user);
-- Update: USING gates which rows you may touch, WITH CHECK gates what they become.
-- Both are needed, or a user can update their row into someone else's.
create policy documents_update_own
on public.documents
for update
to <your_app_role>
using (owner = current_user)
with check (owner = current_user);
An INSERT policy "cannot have a USING expression". A USING-only policy on UPDATE or ALL implicitly reuses that expression as its WITH CHECK, which is why some wrong policies appear to work until somebody probes them.
On Supabase, authenticated is a real role on every project and the predicate compares auth.uid() to your owner column:
create policy documents_select_own
on public.documents
for select
to authenticated
using ((select auth.uid()) = user_id);
The subselect is a performance recommendation, not a correctness one. The full four-policy version: how to fix Supabase RLS.
Which of your tables have RLS off
rowsecurity = true is not the same as protected. A table can have RLS on with zero policies, safe and silently broken, or RLS on with one using (true) policy, wide open next to a green checkmark. This query gives all three states at once, scoped to the schema that is actually served over HTTP:
select t.schemaname,
t.tablename,
t.rowsecurity as rls_enabled,
count(p.policyname) as policy_count
from pg_tables t
left join pg_policies p
on p.schemaname = t.schemaname
and p.tablename = t.tablename
where t.schemaname in ('public')
group by t.schemaname, t.tablename, t.rowsecurity
order by t.rowsecurity, policy_count;
The schema filter is the important line. Widen it and you collect pg_toast plus every managed table your host installed, most of which run with RLS off on purpose and none of which your API serves. Supabase's lint 0013 draws the same boundary, skipping auth, realtime, storage, vault, supabase_migrations, extensions, graphql, pgsodium and net. Add your own exposed schemas to the in list, nothing else.
Read the result as three states, all of them about your exposed schema only:
rls_enabled = false: anyone who can reach that API reads everything in the table. On Supabase, per its own linter, that is anyone with the project's URL.rls_enabled = true, policy_count = 0: deny-all. Safe, and if your app expects to read this table from the client, it is broken in a way you may not have noticed.rls_enabled = true, policy_count > 0: now go read what the policies actually say, becauseusing (true)counts as a policy.
That query only works if you can already log into the database. The reason RLS matters is that strangers cannot, and they do not need to. Checking from the outside takes no credentials at all.
Find out if your database is answering strangers
Who bypasses RLS
Policies do not apply to everyone. Postgres: "Superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table." Table owners normally bypass row security as well, unless the table is set to force row level security.
Those are two separate exemptions, and conflating them is the most repeated bad advice about testing. FORCE removes the owner exemption and does nothing about BYPASSRLS. The Supabase SQL editor connects as postgres, and select rolbypassrls from pg_roles where rolname = 'postgres' returns true there, so queries you run in it sail past your policies however many times you run FORCE. Test the way your users arrive: an anon or authenticated client, or set local role authenticated inside a transaction.
The hosted equivalent is the secret key. Supabase's secret and legacy service_role keys "provide full access to your project's data, bypassing Row Level Security." One leaked secret key makes every policy irrelevant at once.
What generated RLS articles get wrong
- "A stricter policy tightens access." Permissive policies combine with
OR. One leftoverusing (true)re-opens the whole table no matter how careful the others are.AS RESTRICTIVEpolicies combine withAND, but they only subtract. Postgres notes that at least one permissive policy has to grant access before a restrictive one can usefully reduce it, and that if only restrictive policies exist, no records are accessible at all. - "RLS covers my views too." By default a view applies "the row-level security policies of the view owner", not the caller's. You need
security_invoker = true, available from PostgreSQL 15. A view owned by a privileged role is a tunnel through your policies. - "RLS replaces table grants." It filters rows inside what the SQL privilege system already allows. Two separate gates, which is why
revokestill belongs in the Supabase security checklist. - "The anon key is a secret, rotate it if it leaks." It is designed to be public. Also:
anonandservice_roleare being renamed to publishable (sb_publishable_) and secret (sb_secret_) keys, with the legacy pair deprecated by the end of 2026. - "RLS protects the keys in my frontend bundle." Category error. A Stripe
sk_key in aNEXT_PUBLIC_variable is untouched by any policy ever written. Different problem, different fix: how to find exposed API keys.
Postgres RLS is not SQL Server RLS, or Power BI RLS
Three products use these words for three mechanisms. SQL Server has had row level security since 2016, built with CREATE SECURITY POLICY plus predicates written as inline table-valued functions: filter predicates for reads, block predicates for writes. It also inverts the Postgres rule on owners: dbo and the table owner are filtered. Power BI RLS is a third thing again, DAX roles over a semantic model. No syntax carries across, and blending them is the fastest way to spot an unverified article.
Do you need RLS if your app has a backend?
Sometimes, honestly, no. If your database is only ever reached by a server you control, authorization can live in that server, and plenty of production systems run that way.
It stops applying the moment the database answers the browser directly. That is Supabase's default for the public schema, and Supabase words it as a requirement: RLS "must always be enabled on any tables stored in an exposed schema." The line is not backend versus no backend. It is whether a stranger's browser can address your tables. If it can, the policy is the boundary.
CVE-2025-48757 was assigned for exactly this shape of failure. Per the discoverer's writeup, "a vulnerability exists in deployed Lovable-generated projects due to default insufficient Row Level Security (RLS) policies on client-controlled direct database requests." Discovered 2025-03-20, disclosed 2025-05-29. The apps worked the whole time.