What Is Row Level Security? The WHERE Clause Postgres Adds for You

What is row level security, in plain terms: a Postgres rule that filters rows per caller. Turn it on, write a policy, and find the tables still missing it.

By Artem Vysotskyi · Updated

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, because using (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 leftover using (true) re-opens the whole table no matter how careful the others are. AS RESTRICTIVE policies combine with AND, 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 revoke still belongs in the Supabase security checklist.
  • "The anon key is a secret, rotate it if it leaks." It is designed to be public. Also: anon and service_role are 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 a NEXT_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.

Test your app the way a stranger would, no login needed

FAQ

Why does my select return an empty array when the table has rows?

Almost always because RLS is enabled and either no policy exists or your request does not satisfy the one that does. Postgres applies a default-deny policy when a table has row security on and no policies, so every row is hidden. Reads fail silently by design, so you get an empty list instead of an error.

What is the difference between USING and WITH CHECK?

USING decides which existing rows a command is allowed to see or touch, and rows that fail it are silently suppressed. WITH CHECK validates the row you are trying to write, and raises an error when the new row fails. An INSERT policy can only have WITH CHECK, and an UPDATE policy usually needs both, or a user can edit their own row into someone else's.

Who bypasses row level security?

Superusers and any role with the BYPASSRLS attribute always bypass it. Table owners bypass it too, unless you run ALTER TABLE with FORCE ROW LEVEL SECURITY, which removes the owner exemption only and has no effect on BYPASSRLS. That matters on Supabase, where the SQL editor connects as postgres and postgres carries BYPASSRLS, so the SQL editor is the wrong place to test a policy. Secret and service_role keys bypass RLS on every table too, which is why a single leaked secret key voids every policy you wrote.

Do I need RLS if my app has a backend?

If your database is only ever reached through a server you control, authorization can live in that server. If the database answers the browser directly, which is the default for the public schema on Supabase, then RLS is the only boundary, and Supabase treats it as mandatory for any table in an exposed schema.

Is row level security the same in Postgres and SQL Server?

No. Postgres uses CREATE POLICY with USING and WITH CHECK expressions. SQL Server uses CREATE SECURITY POLICY with predicates written as inline table-valued functions, split into filter predicates and block predicates. They also disagree on owners: Postgres exempts the table owner by default, SQL Server filters dbo and the table owner like anyone else.

Find out in 30 seconds

Paste your URL and LeakRank shows what a stranger can actually read from your app. Free, no signup.

Scan my app free →

Related guides