HomeGuidesSupabase RLS on but using (true)? Find permissive policies
Guide
Supabase RLS on but using (true)? Find permissive policies
RLS is enabled and Security Advisor looks quiet, yet anyone can read the table. Find using (true), to public, and signed-in-only policies with one SQL query.
Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.
Turning on Row Level Security in Supabase does not make a table private. It makes Postgres ask a policy about every row. If the policy answers true for every row, nothing is filtered, and the table is as open as if RLS were off. The dashboard still shows RLS as enabled, the table no longer has the "RLS disabled" error, and Security Advisor often stays quiet.
Three policy shapes cause most of this: using (true) (often with no to clause, so it applies to everyone, signed out included), a strict owner policy that sits next to a true policy and gets overruled by it, and "signed-in users only" checks like auth.uid() is not null, which let any account read everyone's rows. This guide shows why each one leaks, gives one SQL query that finds all three, and fixes them with code we ran.
If you are working through Security Advisor findings in general, start with Supabase Security Advisor warnings explained. This page goes deeper on policies that pass the advisor but still leak.
Checked and tested on October 21, 2026 against the Supabase Row Level Security, RLS performance, column level security, and Securing your API docs, the current Security Advisor lint SQL in the open source splinter repo, and the PostgreSQL and PostgREST docs linked below. We ran every SQL block and the curl loop on this page on PostgreSQL 17.11 behind PostgREST 16.5 (the API layer Supabase uses) with @supabase/supabase-js 2.118.0 and Supabase-style roles and default grants: 67 checks, all passing. VibeLocked is independent and not affiliated with Supabase.
The short version
- RLS on is not the same as private. A policy that is always true lets every role it names read or write every row.
- No
toclause meanspublic, which includesanon.using (true)withouttohands the table to signed-out visitors. - Permissive policies OR together. One
truepolicy defeats every strict policy beside it for the same command. - "Signed in" is not "owner".
auth.uid() is not nullandauth.role() = 'authenticated'let any account see every row. With open signup, that is anyone. - Security Advisor misses most of these. Its always-true lint skips
SELECTon purpose and does not look for signed-in-only checks. In our test it flagged 1 of 5 leaking policies. - Run the audit query below, fix each finding with owner checks per command, and keep
using (true)only for public, read-only data. - Prove it with a logged-out curl and an SQL editor test as a second user. Testing as
postgresor with the secret key proves nothing.
Why RLS on plus using (true) protects nothing
Supabase's RLS guide says to think of a policy "as adding a WHERE clause to every query." using ((select auth.uid()) = user_id) becomes "only rows where I am the owner." using (true) becomes where true, which is every row.
Who gets those rows depends on the to clause. PostgreSQL's CREATE POLICY docs: "The default is PUBLIC, which will apply the policy to all roles." On Supabase, a signed-out request with your publishable (or legacy anon) key runs as the anon role, and a signed-in one as authenticated (see the API keys guide). Both are members of PUBLIC. So a policy with no to clause reaches signed-out visitors. The RLS guide is direct about it: "A policy that reads to anon using ( true ) grants every unauthenticated visitor read access to every row the role can already reach through grants."
Grants are the other half. On existing projects, Supabase's Securing your API guide says new tables in public get select, insert, update, delete and more for anon, authenticated, and service_role by default. Grants decide whether a role can touch the table at all, and policies decide which rows. With full grants and a true policy, nothing stands in the way. On a DELETE policy that means one signed-out request can empty the table. Recover deleted Supabase rows and lock DELETE down covers backups by plan and the revoke that stops it.
What AI tools write
Ask an AI coding tool to "fix the empty list" or "let the checkout page save orders" and you often get policies like these. We created them on tables that already had RLS enabled and seeded data for three users, Alice, Bob, and Mallory:
-- 1. The Dashboard template name, with no "to" clause, so it applies to everyone
create policy "Enable read access for all users"
on public.profiles for select
using (true);
-- 2. A correct owner policy...
create policy "Users read their own notes"
on public.notes for select
to authenticated
using ((select auth.uid()) = user_id);
-- ...plus the one added later when the shared notes page came up empty
create policy "Allow read for shared notes page"
on public.notes for select
using (true);
-- 3. "Only logged-in users can see messages"
create policy "Authenticated users can read messages"
on public.messages for select
to authenticated
using (auth.uid() is not null);
create policy "Authenticated users can update messages"
on public.messages for update
using (auth.role() = 'authenticated');
-- 4. "Let the checkout page create orders"
create policy "Anyone can insert orders"
on public.orders for insert
with check (true);
The first name is a stock one from Supabase tutorials and policy templates, so it looks official. Here is what each one did in our test, with only the publishable key unless noted:
- profiles: a signed-out request to
/rest/v1/profilesreturned all 3 profiles, emails included.pg_policiesshows the policy's roles as{public}. - notes: the owner policy is correct, and it did not matter. A signed-out request returned all 4 notes, private ones included, and so did
supabase.from('notes').select()in supabase-js. Signed in as Alice, she got Bob's private notes too. - messages, read: signed out got 0 rows, which looks right. Signed in as Mallory, who is in neither conversation, got both messages between Alice and Bob, including "the door code is 4512".
- messages, update: Mallory rewrote Alice's message with a
PATCH. A signed-outPATCHchanged 0 rows, so a logged-out test alone would call this table safe. - orders: a signed-out
POSTcreated an order for Bob withstatus = 'paid'(HTTP 201).
None of this needs a hacker. Your project URL and publishable key are in your frontend bundle by design, and table names are in every supabase.from('...') call.
Why one true policy beats your strict one
PostgreSQL's CREATE POLICY docs: permissive policies "will be combined together using the Boolean 'OR' operator," and "Policies are permissive by default." Restrictive policies are combined with AND, and "a record is only accessible if at least one of the permissive policies passes, in addition to all the restrictive policies."
So for the notes table the effective rule was:
((select auth.uid()) = user_id) OR true
That is true for every row. The order you created the policies in does not matter, and neither does how strict the first one is. This is why a table can look fine when you read its main policy and still leak: someone (often an AI tool, while fixing a different bug) added a second policy for the same command. The same goes for for all policies, which count for select, insert, update, and delete at once.
Splinter's multiple permissive policies lint (0006) explains the same OR rule and did flag notes in our test. But it is filed as a PERFORMANCE lint, so it shows under Performance Advisor, not Security Advisor, and it reads like a speed tip.
The "authenticated only" trap
Many prompts say "only logged-in users can see this", and the model writes exactly that: to authenticated using (auth.uid() is not null), or using (auth.role() = 'authenticated'). Supabase's own RLS guide shows a starter policy named "Public profiles are viewable only by authenticated users" with to authenticated using ( true ). That is fine for profile cards everyone in your app may see. It is a leak for messages, orders, invoices, or anything that belongs to one user.
These checks are about the caller, not the row. They give the same answer for every row, so whoever passes gets all of them. In a Supabase app with open signup, "authenticated" means anyone with an email address. With anonymous sign-ins turned on it does not even take that: anonymous users get the authenticated role too, so they pass the same checks (anonymous sign-ins and is_anonymous). Our test proved both halves: Mallory, a fresh account, read and edited Alice and Bob's private messages, while a signed-out request saw nothing. That last part is why this one survives so long: the logged-out check everyone does first comes back clean.
The fix is to compare the user to a column on the row: (select auth.uid()) = user_id, or (select auth.uid()) in (sender_id, recipient_id) for a two-party row. This is the same bug as an IDOR, written into a policy.
What Security Advisor flags, and what it misses
Security Advisor runs the lints from splinter. The one for this problem is 0024, named rls_policy_always_true ("RLS Policy Always True", level WARN). Its lint docs say always-true policies "effectively bypass the security that RLS is meant to provide" and are common when "developers create placeholder policies during development and forget to update them."
The lint SQL is narrower than those docs suggest. Its own comment says: "SELECT with (true) is often intentional and documented, so we only flag UPDATE/DELETE." In detail, it reports a permissive policy for public, anon, or authenticated on a table with RLS on when:
- the command is
UPDATE,DELETE, orALLandusingistrueor1=1(or missing), or with checkistrueor1=1, or missing on anINSERT.
It compares the expression text after removing spaces and lowercasing, so it catches true and 1=1, not "always true for any signed-in user."
We ran that lint SQL on our test database. Of the 5 leaking policies above, it reported 1, the with check (true) insert on orders. It did not report the two select ... using (true) policies (profiles and notes), the auth.uid() is not null read, or the auth.role() = 'authenticated' update. So a clean 0024 tells you no table has an always-true write policy. It does not tell you your data is private.
Other advisor checks cover nearby problems: 0013 (RLS disabled in public), 0008 (RLS enabled but no policy, which blocks everything and is safe but broken), and 0015 (policies that read user_metadata). The Security Advisor guide covers each.
Find them: one audit query
Run this in your project's SQL editor. It lists every permissive policy for public, anon, or authenticated, in your exposed schemas, that does not look at the row it is judging, plus any policy whose using or with check is literally true.
The trick is in reads_row. When you create a policy, Postgres records which columns of the table its expressions use in pg_depend. A policy that uses no column of its own row cannot tell rows apart, so it gives every row the same answer: true, 1=1, auth.uid() is not null, auth.role() = 'authenticated', and admin checks all land here. An owner check uses user_id, so it does not.
with pol as (
select n.nspname as schema_name,
c.relname as table_name,
p.polname as policy_name,
case p.polcmd when 'r' then 'SELECT' when 'a' then 'INSERT'
when 'w' then 'UPDATE' when 'd' then 'DELETE' else 'ALL' end as command,
case when p.polroles = '{0}' then 'public'
else (select string_agg(r::regrole::text, ', ')
from unnest(p.polroles) as r) end as roles,
pg_catalog.pg_get_expr(p.polqual, p.polrelid) as using_expr,
pg_catalog.pg_get_expr(p.polwithcheck, p.polrelid) as check_expr,
-- does the policy read any column of the row it is judging?
exists (
select 1 from pg_catalog.pg_depend d
where d.classid = 'pg_catalog.pg_policy'::pg_catalog.regclass
and d.objid = p.oid
and d.refobjid = p.polrelid
and d.refobjsubid > 0
) as reads_row,
-- is there a restrictive policy on the same table and command?
exists (
select 1 from pg_catalog.pg_policy r
where r.polrelid = p.polrelid
and not r.polpermissive
and r.polcmd in (p.polcmd, '*')
) as has_restrictive,
coalesce(pg_catalog.obj_description(p.oid, 'pg_policy'), '')
ilike 'public:%' as marked_public
from pg_catalog.pg_policy p
join pg_catalog.pg_class c on c.oid = p.polrelid
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
where p.polpermissive
and c.relrowsecurity
and n.nspname = any (array['public']) -- add every schema under "Exposed schemas"
), norm as (
select pol.*,
regexp_replace(lower(coalesce(using_expr, '')), '[\s()]', '', 'g') as u,
regexp_replace(lower(coalesce(check_expr, '')), '[\s()]', '', 'g') as w
from pol
where roles = 'public' or roles ~ '\m(anon|authenticated)\M'
)
select table_name, policy_name, command, roles, using_expr, check_expr,
case
when not reads_row and (case when command = 'INSERT' then w else u end)
~ '^(selectauth\.(uid|role|jwt)as(uid|role|jwt)|auth\.(uid|role|jwt))(isnotnull|=''authenticated''::text)$'
then 'any signed-in user, all rows'
when not reads_row and coalesce(using_expr, check_expr) ~* 'auth\.(uid|role|jwt)\('
then 'anyone who passes the check, all rows'
when not reads_row and (roles = 'public' or roles ~ '\manon\M')
then 'everyone, signed out too, all rows'
when not reads_row
then 'any signed-in user, all rows'
when u in ('true', '1=1')
then 'any existing row (using is true)'
else 'any new values (with check is true)'
end as who_gets_in,
has_restrictive,
marked_public
from norm
where not reads_row
or u in ('true', '1=1')
or w in ('true', '1=1')
order by marked_public, table_name, policy_name;
How to read who_gets_in:
everyone, signed out too, all rows: anyone with your publishable key. Fix today unless the data is meant to be public.any signed-in user, all rows: any account. Fix unless every user should see or change every row.anyone who passes the check, all rows: usually an admin or plan check. Fine if that check reads a table users cannot edit (notuser_metadata); otherwise fix it.any existing row (using is true): an update or delete policy that can touch other people's rows. On a parent table it reaches further, becauseon delete cascaderemoves the child rows too, and RLS on the child table does not stop it (ON DELETE CASCADE and RLS).any new values (with check is true): an insert or update that can write anything, including someone else'suser_id.has_restrictive: a restrictive policy also covers this command. Read it to see whether it closes the gap.marked_public: you marked the policy as intentionally public (see the public catalog section). These sort last.
On our test database, before the fix, the query listed exactly the 5 bad policies and not the correct owner policy. We also added 12 more policies one at a time to check the edges. It listed using (1 = 1), (select auth.uid()) is not null, (select auth.jwt()) is not null, an update with with check (true), an update with using (true), an admin check, an insert with with check (true), and to authenticated using (true). It did not list using (is_shared), an owner delete policy, a to service_role policy, or a restrictive MFA policy. After the fix below it returned 0 rows.
Two limits. It flags any row-blind policy, so a legitimate admin policy shows up for review. And it cannot judge a policy that reads the row but still says yes too often, like using (user_id is not null). Skim the using_expr column of pg_policies once anyway. If you expose more schemas than public (Dashboard, API settings, Exposed schemas), add them to the array.
Fix: owner checks, one policy per command
Replace each finding with a policy that names its role and compares the user to the row. These are the fixes we ran for the four tables:
-- profiles: each user reads only their own row
drop policy "Enable read access for all users" on public.profiles;
create policy "Users read their own profile"
on public.profiles for select
to authenticated
using ((select auth.uid()) = id);
-- notes: keep the owner policy, replace the true one with what the share page needs
drop policy "Allow read for shared notes page" on public.notes;
create policy "Anyone can read notes marked shared"
on public.notes for select
to anon, authenticated
using (is_shared);
-- messages: only the two people in the conversation
drop policy "Authenticated users can read messages" on public.messages;
drop policy "Authenticated users can update messages" on public.messages;
create policy "Participants read their messages"
on public.messages for select
to authenticated
using ((select auth.uid()) in (sender_id, recipient_id));
create policy "Senders edit their own messages"
on public.messages for update
to authenticated
using ((select auth.uid()) = sender_id)
with check ((select auth.uid()) = sender_id);
-- orders: signed-in users create orders for themselves only
drop policy "Anyone can insert orders" on public.orders;
create policy "Users create their own orders"
on public.orders for insert
to authenticated
with check ((select auth.uid()) = user_id);
Results after the fix: signed out got 0 profiles, 0 messages, 0 orders, and only the 1 note marked shared. Alice saw her own profile and her 2 notes. Bob saw his 2 notes plus Alice's shared one, not her private one. Mallory saw 0 messages, and her PATCH on Alice's message changed 0 rows. Alice could edit her own message, but trying to set sender_id to Bob failed with 403 and 42501 (the with check). A signed-out insert into orders failed with 401 and 42501, Mallory inserting an order for Bob failed with 403, and Mallory inserting her own order worked. The audit query and lint 0024 both returned 0 rows.
What makes these safe:
to authenticated(orto anon, authenticatedon purpose) on every policy. Supabase's RLS guide says to always name the role, and that it stops the policy from running foranonat all.(select auth.uid()), notauth.uid(). Same result, but Supabase lists "call functions with select" among the policy rules in its RLS performance guide, and the auth_rls_initplan lint docs (0003) explain why: wrapped inselect,auth.uid()runs once per query instead of once per row.- One policy per command, instead of
for all. It is easier to read, and an update policy gets bothusing(which rows you can touch) andwith check(what the row can look like after). - A narrow public policy instead of
true. The share page only needed notes withis_shared, so that is whatusing (is_shared)allows. This is OR working for you: owner OR shared.
If an upsert fails after this cleanup, do not bring true back for insert or update. In our upsert test, true policies let one signed-in user overwrite another user's profile with a 200 response. Supabase upsert new row violates row-level security has the owner policy set that upserts need.
Two things a policy cannot fix. An orders insert policy stops Mallory from creating orders for Bob, but not from inserting her own order with status = 'paid'. Status and amounts should be written by your server after payment (see Stripe price and credits tampering), and columns users must never change need column grants or a trigger (Supabase column security). And if a policy "fix" triggers an infinite recursion error, do not go back to true; the recursion guide has the safe pattern.
A restrictive policy as a safety net
If you cannot clean up every permissive policy today, a restrictive policy caps them all:
create policy "Owner only, whatever other policies say"
on public.notes
as restrictive
for select
to anon, authenticated
using ((select auth.uid()) = user_id);
We added this to the vulnerable notes table with the true policy still in place. A signed-out request then got 0 notes, Alice got only her 2, and Bob only his 2. The audit query still listed the true policy, now with has_restrictive = true.
Two cautions. A restrictive policy narrows every permissive policy, the good ones too: here it also hid the shared notes the share page needed, so a restrictive rule must allow every legitimate case (for example using ((select auth.uid()) = user_id or is_shared)). And with only restrictive policies and no permissive one, Postgres returns nothing. Use restrictive policies for rules that must hold everywhere (owner, tenant, MFA level), and still drop the true policy.
When using (true) is fine
Some tables really are public: a product catalog, published posts, a changelog, a list of plan names. For those, using (true) on a select policy is the honest way to say "everyone may read this." The Supabase RLS guide does exactly this for an announcements feed. Make it read-only and make it obvious:
-- Read-only for everyone: one select policy, and no insert, update or delete policy
create policy "Anyone can read products"
on public.products for select
to anon, authenticated
using (true);
comment on policy "Anyone can read products" on public.products
is 'public: product catalog shown on the pricing page';
-- Take away the write grants too, so a future "for all" policy cannot reopen them
revoke insert, update, delete, truncate on public.products from anon, authenticated;
-- Hide columns visitors should never see (here, what each product costs you)
revoke select on public.products from anon, authenticated;
grant select (id, name, price_cents) on public.products to anon, authenticated;
In our test, a signed-out request for select=id,name,price_cents returned both products. select=* was refused with 401 and 42501, because cost_cents is not granted, and a signed-in request for cost_cents got 403. A signed-out insert got 401 with 42501, and a signed-in update or delete got 403. The audit query listed only this policy, with marked_public = true, and lint 0024 did not flag it because it is a select policy.
What this does:
- Select only. No insert, update, delete, or
for allpolicy foranonorauthenticated. - Write grants revoked. Even if someone later adds a careless
for all using (true)policy, the grants still say no. - Column grants. The policy decides rows; grants decide columns. Supabase's column level security docs note that restricted roles "cannot use the wildcard operator (
*)", so your frontend must list columns:.select('id, name, price_cents'). If that is too fiddly, put private columns in a separate table. - A comment.
comment on policy ... is 'public: ...'records the decision. The audit query sorts these last, and the next person (or AI tool) can see it was on purpose.
Never use true on a table with emails, user ids tied to activity, messages, orders, tokens, or anything a user would not post publicly.
Test it as a signed-out visitor
Run this from your own terminal against your own project, with only the publishable key (or the legacy anon key). Replace the table names with yours. Never use the secret or service_role key here: it bypasses RLS, so every table looks fine.
export SUPABASE_URL="https://<project-ref>.supabase.co"
export SUPABASE_PUBLISHABLE_KEY="sb_publishable_..."
# Signed out: only the publishable key, no Authorization header
for t in profiles notes messages orders; do
printf '%s: ' "$t"
curl -s "$SUPABASE_URL/rest/v1/$t?select=*&limit=3" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY"
echo
done
Every private table should print [] (or a permission error). Any table that prints rows to a signed-out request is either meant to be public or is your leak. In our test, before the fix, profiles and notes printed rows and messages and orders printed []. After the fix, only the one shared note came back.
Note what this does not catch: messages printed [] both before and after, because its leak was for signed-in users. That needs the second test.
Test it as another user in the SQL editor
The SQL editor runs as postgres, which bypasses RLS, so a plain select there proves nothing. Switch role inside a transaction that rolls back, and set the JWT claims Supabase's auth.uid() reads. Use a real user id from your test project as user B:
begin;
-- As a signed-out visitor
set local role anon;
select 'anon' as who, count(*) as notes_visible from public.notes;
-- As user B, a normal signed-in account
reset role;
set local role authenticated;
select set_config('request.jwt.claims',
'{"sub": "<user-b-uuid>", "role": "authenticated"}', true);
select 'user B' as who,
count(*) as notes_visible,
count(*) filter (where user_id <> '<user-b-uuid>') as other_peoples_notes
from public.notes;
rollback;
other_peoples_notes should be 0, or only rows you meant to share. Before the fix, our run printed anon 4 and user B 4 with 2 of other people. After the fix it printed anon 1 and user B 3 with 1 of other people, the note Alice marked shared. Run the same pattern for each table with user data, changing the table and owner column. To keep it fixed, turn these into tests that run on every pull request: testing Supabase RLS in CI shows how with pgTAP.
Common AI-generated mistakes
- Adding
using (true)to make an empty list or a 42501 error go away. The error was RLS working. Fix the grant or the policy condition. If it sayspermission denied for table, it is a missing grant, which every new table will hit after Supabase's October 30, 2026 change: grant only what each role needs. - Leaving out
to, so the policy applies topublic, signed-out visitors included. - Adding a second permissive policy for a new feature (share page, admin view, team view) without noticing it overrides the owner policy.
auth.uid() is not nullorauth.role() = 'authenticated'as the whole check on user data.for all using (true)on a table the app only needed to read.with check (true)on insert, letting anyone write rows for anyuser_idor with anystatus.- Update policies without
with check, or withwith check (true), so a user can hand their row to someone else. - Admin checks on
user_metadata, which users can edit. Supabase's RLS guide warns about this. - Treating a clean Security Advisor as proof. Lint 0024 skips select policies by design.
- Testing only logged out, or only in the SQL editor as
postgres.
Checklist
- The audit query returns no rows, or only policies marked public that you reviewed
- No policy on user data uses
true,1=1,auth.uid() is not null, orauth.role() = 'authenticated'as its whole check - Every policy names its role with
to - Each table has at most one permissive policy per command per role, or you know why it has more
- Owner checks use
(select auth.uid())compared to a column on the row - Update policies have both
usingandwith check - Public tables are select-only, have write grants revoked, and grant only the public columns
- Restrictive policies allow every legitimate case and are not the only policy on the table
- Lint 0024 and 0006 are reviewed in Security and Performance Advisor
- The logged-out curl loop prints
[]for every private table - The SQL editor test as user B shows 0 rows of other people's data
- A CI test fails if a policy change opens a table again
Then go through every table with the Supabase RLS checklist, and check that views and functions and functions called through rpc do not skip your policies.
Copy-paste prompt for your AI coding tool
Paste this into Cursor, Claude Code, Lovable, or Bolt. Share code and table names only, never keys, passwords, or production data.
Audit every Row Level Security policy in this repo's Supabase migrations. For each table, list each policy with its command, its to roles (no to clause means public, which includes anon), whether it is permissive or restrictive, and its using and with check expressions. Flag every policy on a table with user data that uses true, 1=1, auth.uid() is not null, auth.role() = 'authenticated', or any condition that does not compare the current user to a column of the row. Remember that permissive policies are combined with OR, so one true policy overrides every strict policy for the same command. Then write one new migration, and show it to me before running anything: 1) Drop each flagged policy. 2) Replace it with one policy per command, each with to authenticated, using (select auth.uid()) compared to the owner column, and with check on insert and update. 3) Where a feature needs public reads (a share page, a public profile), add a narrow select policy on a column like is_shared or is_public, not true. 4) For tables that are truly public, keep a select policy with using (true) only, add comment on policy ... is 'public:
', revoke insert, update, delete from anon and authenticated, and grant select only on the public columns. 5) Do not disable RLS, do not use the service role key in client code, and do not read roles from user_metadata. Finally, list every supabase.from() call in the frontend and say which policy allows it.
Read the migration before it runs, try it on a branch or local database, then run the curl loop and the SQL editor test above. More prompts are in the security review prompt guide.
Sources
- Supabase: Row Level Security (policies as WHERE clauses, to clause, anon and authenticated roles, to anon using (true), user_metadata)
- Supabase: RLS performance (call functions with select, specify roles)
- splinter docs: lint 0003 auth_rls_initplan (auth.uid() once per query)
- Supabase: Column level security (column grants, no select *)
- Supabase: Securing your API (default grants to anon and authenticated)
- Supabase: API keys (publishable key maps to anon)
- Supabase: Postgres roles
- Supabase: Database advisors
- Supabase: lint 0024 permissive RLS policy
- Supabase: Kotlin tutorial (an "Enable read access for all users" using (true) policy)
- splinter: lint 0024 rls_policy_always_true (SQL, select excluded)
- splinter docs: lint 0024 permissive RLS policy
- splinter: lint 0006 multiple_permissive_policies (SQL, PERFORMANCE category)
- splinter docs: lint 0006 multiple permissive policies (OR semantics)
- PostgreSQL: CREATE POLICY (default PUBLIC, permissive OR, restrictive AND)
- PostgreSQL: Row Security Policies
- PostgreSQL: pg_policy catalog
- PostgreSQL: pg_policies view
- PostgreSQL: pg_depend catalog
- PostgreSQL: COMMENT (comment on policy)
- PostgreSQL: GRANT (column privileges)
- PostgreSQL: SET ROLE
- PostgreSQL: set_config and system administration functions
- PostgREST: Authentication (anonymous role, JWT role)
- PostgREST: Tables and views (horizontal and vertical filtering)
- PostgREST: Errors (42501 as 401 or 403)
Questions
Is my Supabase table safe if RLS is enabled?
Not by itself. RLS on means Postgres checks a policy for every row. If that policy is using (true), the check always passes, so every role the policy names can read every row. A policy with no to clause applies to public, which includes anon, so a signed-out visitor with only your publishable key gets the whole table. Check what the policies say and test with a logged-out request, not just the RLS switch.
Does Supabase Security Advisor flag using (true) policies?
Only some of them. The rls_policy_always_true lint (0024) flags always-true policies for INSERT, UPDATE, DELETE, and ALL, but its SQL skips SELECT policies on purpose, because a public read is often intended. In our test it reported 1 of 5 leaking policies: the insert with check (true). It missed two select using (true) policies and two policies that let any signed-in user read or edit every row.
Why does a strict policy not help when another policy says using (true)?
Postgres combines permissive policies with OR. A row is visible if any one permissive policy for that role and command passes. A using (true) policy passes for every row, so the strict owner policy next to it no longer matters. In our test, adding a true policy beside a correct owner policy let a signed-out visitor read all 4 notes. Drop the true policy, or add a restrictive policy, which is combined with AND.
Is auth.uid() is not null a safe RLS policy?
Only for data every signed-in user should see. auth.uid() is not null and auth.role() = 'authenticated' say yes for the same people on every row, so any account, including one an attacker signs up for in ten seconds, reads or edits all rows. Compare the user id to a column on the row instead, like (select auth.uid()) = user_id.
When is using (true) fine in Supabase?
For data that is meant to be public and read-only, like a product catalog, published blog posts, or a changelog. Keep it on a select policy only, add no insert, update, or delete policies for anon and authenticated, revoke those write grants, and grant select only on the columns visitors should see. Mark it with a comment so audits can tell it apart from a mistake.