← All posts
·28 min read·GuardLayer

Supabase Row Level Security: the Complete Guide

SupabaseRLSPostgresSecurity

Row Level Security (RLS) is the only thing standing between an anonymous visitor and the rows in your Supabase tables, because every table in the public schema is reachable through the auto-generated API using the anon key that ships in your browser. Enable it on every table (alter table ... enable row level security) and add explicit policies scoped to auth.uid() — a table with RLS off, or a policy of using (true), is effectively public.

Supabase is Postgres with an auto-generated REST and realtime API in front of it. That convenience is also the trap: the moment you create a table in the public schema, it's queryable over the internet with nothing but your anon key — the key that is, by design, embedded in every browser bundle you ship. Row Level Security is the gate that decides which rows each request may see. Get it right and Supabase is genuinely secure by default. Get it wrong and you've published your database.

This guide covers the whole mechanism end to end: the request path, grants versus policies, the full policy grammar, the patterns that actually hold, what bypasses RLS anyway, and how to verify all of it. Each subtopic links to a focused deep-dive when you need the long version.

This isn't hypothetical — CVE-2025-48757. In May 2025, researcher Matt Palmer disclosed 303 endpoints across 170 production apps with Supabase tables readable by anyone holding the public anon key — emails, addresses, even API keys — because RLS wasn't protecting them. The official record scores it CVSS 9.3. The fix was one line per table. Full breakdown: the Lovable RLS vulnerability.

How does Supabase RLS actually work?

RLS is a Postgres feature: when a table has RLS enabled, every query is filtered through policies you define, and any row that doesn't match an allowing policy is invisible — as if it didn't exist. With RLS enabled and no policies, the table denies all access. PostgreSQL states it flatly: "If row-level security is enabled for a table, but no applicable policies exist, a 'default deny' policy is assumed, so that no rows will be visible or updatable." With RLS disabled, Postgres skips the check entirely and the table is fully open through the anon key.

The part almost nobody is shown is what happens between supabase.from('orders').select() and a row coming back. Every fact in the rest of this guide falls out of this sequence:

  1. Your client sends the request with an API key (anon / publishable) and, if the user is signed in, an Authorization: Bearer <access_token> header.
  2. PostgREST — the API layer — opens its Postgres connection as a special role called authenticator. Supabase describes it as a role that "has very limited access, and is used to validate a JWT and then 'change into' another role determined by the JWT verification."
  3. PostgREST verifies the JWT and issues a SET LOCAL ROLE for the transaction: anon when there's no valid user token, authenticated when there is (strictly, whatever the token's role claim names).
  4. PostgREST sets the transaction-local setting request.jwt.claims to the token's payload. auth.uid() is a small SQL function that reads it — it pulls sub out of those claims and casts the result to uuid, which is why comparing it to a text column silently never matches.
  5. Postgres runs your query as that role, and rewrites your policy's USING expression into an implicit predicate that is AND-ed onto the query and evaluated as the table is scanned.

Step 5 is the mental model that matters: a policy is a row filter Postgres injects into your query, not a gate that runs once in front of it. That single fact explains why auth.uid() can be NULL (no JWT ever reached step 4), why the SQL editor lies (it never goes through steps 2–4), why an ORM walks straight past your policies (it skips PostgREST entirely), and why an unindexed policy column is slow (the predicate runs per row).

Two consequences trip up everyone:

  1. Enabling RLS with no policy = locked. Your app suddenly gets empty results or 403s. That's RLS working — you just haven't granted the access you intended yet.
  2. The service_role key ignores RLS completely. It's meant for trusted server code. If it ever reaches the browser, every policy you wrote is bypassed at once — and the same goes for an AI agent handed the keys to your database.

Grants or policies: which lock is actually stopping my query?

There are two locks on every Supabase table, and people burn hours writing policies to fix a grant problem. Supabase puts it in one line: "Grants decide whether a role can run an operation on the table at all. Policies decide which rows that operation applies to."

Postgres checks access in a fixed order, and RLS is dead last:

  1. Database CONNECT
  2. Schema USAGE
  3. Table privilege (SELECT / INSERT / UPDATE / DELETE)
  4. Column and sequence privileges
  5. Row-Level Security policies

If layer 3 fails, Postgres never reaches layer 5 and no policy ever executes. You'll see 42501: permission denied for table — or, because PostgREST may drop an inaccessible table from its schema cache, a plain 404 "table not found in the schema cache." Adding policies changes nothing in either case. That failure path is walked step by step in permission denied for table (42501).

The reverse trap is just as common: adding policies doesn't take grants back. If anon already holds INSERT on a table, a permissive policy is the only thing preventing anonymous writes. The lock-down sequence closes both locks explicitly — enable RLS, revoke everything, then grant back only the verbs you need:

alter table public.reports enable row level security;

revoke all on table public.reports from anon, authenticated;

grant select, insert, update, delete on table public.reports to authenticated;

Since the 2026 Data-API change (changelog #45329), a table reaches the REST and GraphQL API only via an explicit grant to anon and/or authenticated — new projects since 2026-05-30, existing projects from 2026-10-30. That change adjusts exposure, not RLS; the two mechanisms stay separate. See tables not exposed to the Data API for the migration path.

Who is anon, authenticated, and service_role?

TO in a policy names a Postgres role, not a concept. These are the ones you'll meet:

RoleWhat it isDoes RLS apply?
anonUnauthenticated, public access. The role PostgREST uses when no user is logged in.Yes
authenticatedThe role PostgREST switches into when the request carries a valid user JWT.Yes
authenticatorA very limited role for PostgREST. It validates the JWT and then changes into another role.Never queries your tables — it has already switched
service_roleElevated access, behind the secret / sb_secret key. Supabase creates it with the BYPASSRLS attribute.No
postgresThe default admin role. The SQL editor, and what most direct connections and ORMs log in as.No — it owns your tables and holds BYPASSRLS

Two rules follow. First, always name the role with TO. Omit the clause and Postgres defaults the policy to PUBLIC — "which will apply the policy to all roles" — so your carefully written predicate also runs for anon. Second, to authenticated is the correct replacement for a using (auth.role() = 'authenticated') predicate: it's checked by the planner instead of per row, and it can't be spoofed by the token contents.

One wrinkle: users created through anonymous sign-in also arrive as authenticated, so to authenticated alone does not mean "a real account" — see anonymous sign-ins. And on the key side: the anon/publishable key is safe to ship precisely because RLS constrains it, while the new publishable/secret key format makes the dangerous one easier to spot.

What does a Supabase RLS policy actually look like?

Turn RLS on for the table first — this is the one line that separates a private table from a public one:

alter table public.orders enable row level security;

Then write policies. Here is the complete grammar, from the PostgreSQL manual:

CREATE POLICY name ON table_name
    [ AS { PERMISSIVE | RESTRICTIVE } ]
    [ FOR { ALL | SELECT | INSERT | UPDATE | DELETE } ]
    [ TO { role_name | PUBLIC | CURRENT_ROLE | CURRENT_USER | SESSION_USER } [, ...] ]
    [ USING ( using_expression ) ]
    [ WITH CHECK ( check_expression ) ]

Every bracketed clause is optional, and the two defaults are silent and dangerous:

  • AS PERMISSIVE is the default. Permissive policies OR together, so every policy you add can only widen access.
  • TO PUBLIC is the default. A policy with no TO clause applies to every role, anon included.

The manual is explicit: "The default for newly created policies is that they apply for all commands and roles, unless otherwise specified." Write all five clauses, every time.

USING or WITH CHECK: which clause does each command need?

This table is the thing most RLS bugs are hiding behind. USING filters rows that already exist; WITH CHECK validates rows being written.

CommandUSINGWITH CHECKWhat the clauses decide
SELECTYesNoUSING = which rows are returned.
INSERTNot allowedYesNothing exists to filter; WITH CHECK decides if the new row is allowed.
UPDATEYesYesUSING = which rows may be targeted. WITH CHECK = what the row is allowed to become.
DELETEYesNot allowedUSING = which rows may be deleted. No new row to validate.
ALLYesYesCovers all four commands with the same predicate(s).

Postgres rejects the two impossible combinations at create policy time, in as many words: "An INSERT policy cannot have a USING expression, as it only applies in cases where records are being added to the relation," and "A DELETE policy cannot have a WITH CHECK expression... so that there is no new row to check."

Three consequences explain most "my policy doesn't work" reports:

Reads fail silently; writes throw. Verbatim from PostgreSQL: when a USING expression "returns true for a given row then that row is visible to the user, while if false or null is returned then the row is not visible. Typically, no error occurs when a row is not visible." But when a WITH CHECK expression "returns true for a row then that row is inserted or updated, while if false or null is returned then an error occurs." So silence points at USING; a 42501 new row violates row-level security policy points at WITH CHECK — or at a missing grant. That error has its own walkthrough: new row violates row-level security policy.

SELECT rights leak into writes. An UPDATE that reads columns of the row it's changing — "in a WHERE clause or a RETURNING clause, or in an expression on the right hand side of the SET clause" — also needs SELECT rights, and "the appropriate SELECT or ALL policies will be applied in addition to the UPDATE policies." The same holds for any write with RETURNING: "any newly inserted or updated rows from the relation must satisfy the relation's SELECT policies in order to be available to the RETURNING clause." That is exactly what supabase.from('orders').insert(row).select() compiles to. A plain .insert() succeeding while .insert().select() fails is not a bug; it's a missing SELECT policy that the new row must satisfy.

Omitting WITH CHECK on UPDATE or ALL reuses USING. PostgreSQL: "if no WITH CHECK expression is defined, then the USING expression will be used both to determine which rows are visible... and which new rows will be allowed to be added." So a for all using (...) policy does check inserts. The real FOR ALL footgun is different: one predicate is forced to serve as both read filter and write validator, and the moment someone appends with check (true) to make an insert work, writes are open to anything.

How do I write a correct first policy set?

One migration, all four commands, every policy role-scoped, every predicate wrapped for the planner:

alter table public.orders enable row level security;

-- Lock 1: grants. Only authenticated users touch this table at all.
revoke all on table public.orders from anon, authenticated;
grant select, insert, update, delete on table public.orders to authenticated;

-- Lock 2: policies. One per command, each scoped to the caller.
create policy "orders_select_own"
  on public.orders for select
  to authenticated
  using ( (select auth.uid()) = user_id );

create policy "orders_insert_own"
  on public.orders for insert
  to authenticated
  with check ( (select auth.uid()) = user_id );

create policy "orders_update_own"
  on public.orders for update
  to authenticated
  using ( (select auth.uid()) = user_id )
  with check ( (select auth.uid()) = user_id );

create policy "orders_delete_own"
  on public.orders for delete
  to authenticated
  using ( (select auth.uid()) = user_id );

-- Every column a policy filters on needs an index.
create index orders_user_id_idx on public.orders (user_id);

Three details are doing real work. to authenticated stops the predicate from being evaluated for anon at all. (select auth.uid()) — rather than bare auth.uid() — is an uncorrelated subquery, so Postgres plans it as an InitPlan and evaluates the claim once per statement instead of once per row. And the update policy carries both clauses, so a user can neither update someone else's row nor reassign their own row to another user_id.

Here's the opposite — a real migration that creates a table and never enables RLS at all. This is live GuardLayer engine output on that file, not a mockup:

guardlayer scan · supabase/migrations/20260701_orders.sqlLive engine output
Passed with warnings
92/100 · A
  • Warningsupabase/migrations/20260701_orders.sql:2

    Table created without enabling RLS

    Add ALTER TABLE <table> ENABLE ROW LEVEL SECURITY; plus access policies right after the CREATE TABLE.

How do multiple policies combine?

Permissive policies are combined "using the Boolean 'OR' operator." Restrictive policies are combined "using the Boolean 'AND' operator." And with both present, "a record is only accessible if at least one of the permissive policies passes, in addition to all the restrictive policies."

Read that as an operational rule: adding a permissive policy can only ever grant more access. A perfect using ((select auth.uid()) = user_id) policy sitting beside a leftover using (true) policy is worthless — the OR resolves to true for every row. This is why "we have RLS policies" is not the same statement as "our table is protected", and why the Supabase advisor warns about 0006_multiple_permissive_policies (it's a performance lint, but it's also a correctness smell — every one of those policies is evaluated and OR-ed).

When a condition must hold regardless of what any other policy says, use as restrictive. Supabase's own example of this is a second-factor gate, keyed on the JWT's aal claim:

-- Nobody reads or writes this table without a second factor,
-- no matter how many permissive policies exist.
create policy "orders_require_mfa"
  on public.orders
  as restrictive
  for all
  to authenticated
  using ( (select auth.jwt() ->> 'aal') = 'aal2' );

Restrictive policies never grant access on their own — with no permissive policy to pass, the default deny still applies and the table returns nothing. Pair one with the permissive set above.

Which RLS pattern fits my table?

Most tables are one of six shapes. Pick the shape first, then write the four policies around it.

1. Own rows — the default. The predicate above: (select auth.uid()) = user_id.

2. Rows owned through a parent. The child table has no user_id; ownership lives one hop away.

create policy "line_items_select_own"
  on public.line_items for select
  to authenticated
  using (
    exists (
      select 1 from public.invoices i
      where i.id = line_items.invoice_id
        and i.user_id = (select auth.uid())
    )
  );

3. Org / tenant membership.

create policy "invoices_select_tenant"
  on public.invoices for select
  to authenticated
  using (
    tenant_id in (
      select tenant_id from public.memberships
      where user_id = (select auth.uid())
    )
  );

The where user_id = (select auth.uid()) is the entire security property. Drop it and the subquery returns every tenant id in the table, the predicate is true for every row, and you have cross-tenant reads that look correct in review. Full treatment, including the JWT-claim alternative and its staleness problem: Supabase multi-tenant RLS.

4. Public read-only. RLS stays on; you scope the openness to select only.

create policy "posts_public_read"
  on public.posts for select
  to anon, authenticated
  using ( true );

This is the one legitimate using (true) — a select-only policy on data you'd print on a billboard. Never pair it with with check (true), and never write it for all. Why it goes wrong everywhere else: the using (true) trap.

5. Role or claim gate. Read authorization claims from app_metadata, never user_metadata:

create policy "orders_admin_read"
  on public.orders for select
  to authenticated
  using ( (select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin' );

Supabase is unambiguous here: raw_user_meta_data "can be updated by the authenticated user" and "is not a good place to store authorization data"; raw_app_meta_data "cannot be updated by the user." A role in user_metadata is self-service admin — see app_metadata vs user_metadata.

6. A lookup that would recurse. A policy that queries its own table raises 42P17 infinite recursion detected in policy — the classic case is a team_members policy whose subquery selects from team_members. (A policy on orders that reads a separate admins table is a different relation and does not recurse.) Break the loop with a SECURITY DEFINER helper in a schema PostgREST doesn't expose:

create schema if not exists private;

create or replace function private.is_team_member(_team_id uuid)
returns boolean
language sql
stable
security definer
set search_path = ''
as $$
  select exists (
    select 1 from public.team_members m
    where m.team_id = _team_id
      and m.user_id = (select auth.uid())
  );
$$;

-- EXECUTE defaults to PUBLIC — take it back.
revoke all on function private.is_team_member(uuid) from public, anon;
grant execute on function private.is_team_member(uuid) to authenticated;

create policy "team_members_select"
  on public.team_members for select
  to authenticated
  using ( private.is_team_member(team_id) );

The function runs with its owner's rights, and that owner owns team_members — so the read inside it bypasses that table's RLS and the policy is never re-entered. set search_path = '' is not optional: it's what stops a caller-controlled schema from shadowing an unqualified name inside a function running with the owner's privileges. Details: infinite recursion in RLS and SECURITY DEFINER search_path.

The four mistakes that leak a Supabase table

Almost every Supabase data leak is one of these:

  • RLS never enabled / disabled to "fix" a query. The table is public. The blocked query was blocked because no policy granted access — not because RLS was broken.
  • A table created without RLS. New tables aren't protected until you turn it on; it's easy to add one and forget.
  • The using (true) trap. A policy that matches every row. RLS is "on," the dashboard looks green, and the table is still world-readable.
  • A policy that isn't user-scoped. using (auth.role() = 'authenticated') lets any logged-in user read everyone's rows — the classic multi-tenant leak. Scope to auth.uid() = user_id.

A fifth mistake belongs on that list once you start reading claims out of the token: a policy that trusts user_metadata. That field is writable by the user, so a role stored in user_metadata rather than app_metadata is an escalation any account can perform on itself.

Edge Functions are a separate surface: they often run with elevated access, so an unauthenticated Edge Function can sidestep RLS the same way the service_role key does. So can a view: because a Postgres view runs as its owner, a view over an RLS-protected table returns every row until you set security_invoker = on.

Once policies are correct, they also govern your live subscriptions — Realtime authorizes every Postgres Changes event against them, which is why a subscription can go silent the moment RLS goes on.

What bypasses RLS even when your policies are correct?

Perfect policies protect nothing if the query never reaches them as a constrained role. The complete list of ways a request walks past RLS:

  • The service_role / sb_secret key. It authorizes as the service_role Postgres role, which Supabase creates with the BYPASSRLS attribute. Server-side only, always — an exposed service_role key voids every policy at once.
  • The postgres role. Prisma, Drizzle, psql, and any raw DATABASE_URL connection log in as postgres, which owns your tables and holds BYPASSRLS. Your policies are simply not in the code path: does Prisma respect Supabase RLS?
  • Table owners generally. "Table owners normally bypass row security as well, though a table owner can choose to be subject to row security with ALTER TABLE ... FORCE ROW LEVEL SECURITY." Note the asymmetry: FORCE closes the owner hole and does nothing at all to a BYPASSRLS role — "superusers and roles with the BYPASSRLS attribute always bypass the row security system when accessing a table."
  • Views without security_invoker. A view executes with its creator's privileges, and in a Supabase project that creator is usually postgres. On Postgres 15+: alter view <name> set (security_invoker = on);. On older versions, revoke access from anon/authenticated or move the view to an unexposed schema. See SECURITY DEFINER views.
  • SECURITY DEFINER functions. They run with the definer's privileges by design; an unpinned search_path turns that into an injection surface. Pin it: SECURITY DEFINER search_path.
  • Edge Functions holding an elevated keyEdge Function auth.
  • The SQL editor. It runs as postgres. This is why a broken policy and a correct one look identical there, and why the editor is never a test.

What does RLS not protect?

  • Columns. RLS filters rows, never columns. Hiding a column is a column-scoped GRANT (grant select (id, title) on public.posts to authenticated) or a view — a different mechanism entirely. A policy cannot redact a field.
  • Storage. Files are rows in storage.objects, which has its own RLS. Supabase: "By default Storage does not allow any uploads to buckets without RLS policies." Policies there filter on bucket_id and on the path via (storage.foldername(name))[1] — see new row violates RLS on Storage upload. A bucket marked public skips that check for reads entirely: public storage bucket leak.
  • Realtime, implicitly. Postgres Changes are authorized against your SELECT policies, so an RLS mistake shows up as silence on a subscription, not an error.
  • Direct database connections. Anything not going through PostgREST never gets the SET ROLE / request.jwt.claims treatment.
  • Existence, under constraints. "Referential integrity checks, such as unique or primary key constraints and foreign key references, always bypass row security to ensure that data integrity is maintained." A unique-violation error can therefore confirm that a row you cannot see exists — the manual calls this a "covert channel" leak and tells you to design schemas to avoid it.
  • Rate limiting and business logic. RLS answers "may this role touch this row," not "should this happen."

The strongest control isn't a policy at all: a table PostgREST cannot reach. Keep sensitive tables out of the exposed schema entirely (a private schema, reached only through server code or a SECURITY DEFINER RPC) and there is no policy to get wrong.

Why does my query return nothing after enabling RLS?

Work down this list in order. Each branch has a full walkthrough.

  1. No policy exists for that command. RLS default-denies. select * from pg_policies where tablename = 'orders'; — check cmd covers the verb you're running.
  2. auth.uid() is NULL because no JWT reached Postgres — you're in the SQL editor, or your SSR client never forwarded the session. NULL = user_id is NULL, never true, so every row is filtered out silently. See auth.uid() returns NULL and getSession vs getUser.
  3. Missing GRANT — surfaces as 42501 or a PostgREST 404, and no policy ever runs: permission denied for table.
  4. Type mismatch. auth.uid() returns uuid; comparing it against a text column silently never matches: auth.uid() = user_id returns false.
  5. .single() on zero visible rows returns PGRST116, which reads like a missing row but is usually a policy: PGRST116 no rows returned.
  6. An insert failing WITH CHECK throws new row violates row-level security policy: the 42501 write path.
  7. 42P17 infinite recursion — a policy queries its own table: infinite recursion in RLS.

The one rule that routes you fastest: reads go quiet, writes throw. Empty results mean USING (or a NULL auth.uid()). A 42501 means WITH CHECK or a grant.

How do I test my RLS policies?

Not in the SQL editor — it connects as postgres, which bypasses RLS, so a broken policy and a correct one both "pass." You have to run the query as the request role with claims attached. The smallest useful version, runnable right now:

begin;
  set local role authenticated;
  set local request.jwt.claims to
    '{"sub":"11111111-1111-1111-1111-111111111111","role":"authenticated"}';

  -- Should return ONLY that user's rows. If it returns everyone's, the policy is wrong.
  select count(*) from public.orders;

  -- And the unauthenticated case must return zero.
  set local role anon;
  select count(*) from public.orders;
rollback;

set local scopes both changes to the transaction, and the rollback guarantees nothing leaks into your session. Make this an assertion instead of an eyeball check with pgTAP in CI, plus a client-SDK test as a real signed-in user: how to test your Supabase RLS policies.

Why are my RLS queries slow?

Because the predicate is evaluated as the table is scanned — conceptually once per candidate row. Four fixes, in order of impact:

  1. Wrap function calls in a sub-select: (select auth.uid()) = user_id. Postgres plans it as an InitPlan and runs it once per statement. Only valid for expressions that don't depend on the row.
  2. Index every column a policy filters on. In Supabase's published 100K-row benchmarks this alone took a query from 171 ms to under 0.1 ms.
  3. Add to authenticated so the policy is skipped entirely for roles that can never match.
  4. Mirror the predicate in your client query (.eq('user_id', user.id)), so the planner can use the index rather than discovering the constraint inside the policy.

Also restructure membership joins so the subquery filters on the fixed user (tenant_id in (select tenant_id from memberships where user_id = (select auth.uid()))) rather than correlating against each row. Measurements and the EXPLAIN ANALYZE workflow: why Supabase RLS queries are slow.

How do I check if my Supabase RLS is correct?

A fast audit you can run today:

-- Tables in `public` with RLS turned OFF — each one is exposed:
select relname
from pg_class
where relnamespace = 'public'::regnamespace
  and relkind in ('r', 'p')
  and relrowsecurity = false;

Then the four checks that catch the tables where RLS is on and still wrong:

-- 1. RLS enabled but ZERO policies — denies everything (fails closed, but usually unintended).
select c.relname
from pg_class c
where c.relnamespace = 'public'::regnamespace
  and c.relkind in ('r', 'p')
  and c.relrowsecurity
  and not exists (
    select 1 from pg_policies p
    where p.schemaname = 'public' and p.tablename = c.relname
  );

-- 2. Constant-true predicates — the `using (true)` trap, read or write.
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
  and (qual = 'true' or with_check = 'true');

-- 3. Policies with no TO clause — they apply to PUBLIC, which includes anon.
select tablename, policyname, cmd, roles
from pg_policies
where schemaname = 'public'
  and roles::text[] = '{public}';

-- 4. Write privileges handed to anon.
select table_name, privilege_type
from information_schema.role_table_grants
where table_schema = 'public'
  and grantee = 'anon'
  and privilege_type in ('INSERT', 'UPDATE', 'DELETE');

Supabase's Security Advisor runs its own lints. Map each warning to the section above rather than guessing:

LintMeansFix in this guide
0013_rls_disabled_in_publicPublic table, no RLS"How do I write a correct first policy set?"
0008_rls_enabled_no_policyRLS on, zero policiesSame — add the four policies
0007_policy_exists_rls_disabledPolicies written, RLS never enabledalter table ... enable row level security
0024_permissive_rls_policyAlways-true condition"Which RLS pattern fits my table?" (pattern 4)
0015_rls_references_user_metadataPolicy reads user-editable dataPattern 5 — use app_metadata
0010_security_definer_viewView bypasses RLS"What bypasses RLS…"
0003_auth_rls_initplanPredicate re-evaluated per row"Why are my RLS queries slow?"
0006_multiple_permissive_policiesPolicies OR-ing together"How do multiple policies combine?"

Or scan the whole repo at once: GuardLayer reads the SQL in your migrations and flags RLS-off tables (supabase/rls-missing-on-table), using (true) policies (supabase/policy-using-true), and policies whose predicate never references the caller (supabase/policy-no-user-scope), with the SQL fix inline. For a quick one-off, paste a migration into the Supabase security checker — same rules, instant, entirely in your browser. Be clear on the limits: both are static, per-file analysis of what your migrations say, not the live privilege state of your database, and the CI check posts a result rather than blocking a merge on its own (that needs branch protection). Pair them with the catalog queries above, which are the runtime source of truth.

The RLS checklist for every table you ship

  1. enable row level security is in the same migration as create table.
  2. Grants are scoped: revoke all from anon, authenticated, then grant only the verbs you need, to the role that needs them.
  3. A separate policy for select, insert, update, and delete — not one for all.
  4. Every policy carries a TO clause naming a real role.
  5. Every predicate references (select auth.uid()), or a subquery that filters on it.
  6. Every write policy has a WITH CHECK, and update has both clauses.
  7. Every column a policy filters on is indexed.
  8. Authorization claims are read from app_metadata, never user_metadata.
  9. Verified as anon and as a real signed-in user — not in the SQL editor.
  10. The service_role / secret key exists only in server code, and nothing in the app connects as postgres.

FAQ

Do I need RLS if my app has a login? Yes. Auth proves who is calling; RLS decides which rows they may touch. Without RLS, any authenticated user (or anyone with the anon key) can query every row directly through the API, bypassing your app code entirely.

Is it safe that my anon key is public? Yes — as long as RLS is enabled and your policies are correct. The anon key is designed to be public; RLS is what constrains it. The service_role key is the one that must never be public.

Does enabling RLS with no policies make a table private? Yes. RLS fails closed — with it enabled and no applicable policy, Postgres assumes a default-deny and no rows are visible or updatable. You then add policies to grant exactly the access you intend.

Can the service_role key be filtered by RLS? No. service_role bypasses RLS by design. Keep it server-side only and never hand it to the browser or an AI agent.

What is the difference between USING and WITH CHECK? USING filters rows that already exist (SELECT, UPDATE, DELETE) and fails silently — a false or null result just hides the row. WITH CHECK validates rows being written (INSERT, UPDATE) and fails loudly — a false or null result raises an error. An INSERT policy cannot have a USING expression; a DELETE policy cannot have a WITH CHECK expression.

Do I need a separate policy for every command? Practically, yes. A single for all policy forces one predicate to act as both read filter and write validator, and if you omit WITH CHECK Postgres reuses the USING expression as the check. Four explicit policies let each command say exactly what it means and are far easier to audit.

Does enabling RLS break my server code? Only code that calls through PostgREST with the anon key. Server code using the service_role/secret key bypasses RLS and is unaffected — as is any ORM connecting as postgres, which is a problem in its own right rather than a relief.

Why does my policy work in the SQL editor but not in my app? The SQL editor connects as postgres, which bypasses RLS and carries no JWT — so auth.uid() is NULL and your policy is never enforced. Test with set local role authenticated inside a transaction, or through the client SDK as a real user.

Can RLS hide a column? No. RLS filters rows only. To hide a column, use a column-scoped GRANT (grant select (id, title) on public.posts to authenticated) or expose a view with security_invoker = on that omits it.

Does force row level security stop the service_role key? No. FORCE subjects the table owner to policies. Roles with the BYPASSRLS attribute — which service_role and postgres both have — always bypass row security regardless. The control is the connection role, not the table setting.

Does RLS apply inside Postgres functions? A SECURITY INVOKER function (the default) runs as the caller, so policies apply normally. A SECURITY DEFINER function runs as its owner and therefore bypasses RLS on tables that owner owns — which is exactly why it's the standard fix for policy recursion, and exactly why it needs set search_path = '' and a revoked EXECUTE grant.

Should every table have RLS, even lookup tables? Yes, for anything in an API-exposed schema. A read-only lookup table still gets enable row level security plus a for select ... using (true) policy, so anonymous writes are blocked. If a table should never be reachable from the client at all, the stronger move is to keep it out of the exposed schema entirely.

Catch this before it ships — free

GuardLayer scans every push for this and 28 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading

A Solvion project — see also Reglog — EU AI Act changelog, Proceedly, Solenna and Solvion Solutions.