The dangerous thing about a Supabase Row Level Security mistake is that nothing breaks. A denied query returns an empty result, not an error, so a policy that is too strict looks like a missing-data bug, and a policy that is too loose leaks data with no warning at all. In a mobile app, where the anon key ships in the bundle, that silence is the whole problem. Here is why RLS fails quietly and how to make it speak up.
Short answer
Supabase RLS fails silently because a denied query returns an empty result rather than an error. Supabase notes that a policy like using (auth.uid() = user_id) silently denies unauthenticated users, since null = user_id is always false, so a too-strict policy reads as a no-data bug and a too-loose one leaks data quietly. This matters most in mobile apps, because the anon key is public in your bundle and anyone can call the REST API directly, which makes RLS the only guard on your data. Supabase requires RLS on every table in the public schema, and the only safe move is to test policies, not assume.
What you should know
- Denials return empty, not errors: RLS does not throw on a blocked read, so failures look like missing data.
- Two silent modes: a too-strict policy locks data out, and a too-loose one leaks it, both without warning.
- The anon key is public: it ships in your mobile bundle, so anyone can query your REST API directly.
- RLS is the only guard there: with the key exposed, your policies are what stand between a stranger and your data.
- You must test it: empty results do not prove safety, so verify policies as the anon and authenticated roles.
Why is an RLS failure silent?
Because Postgres evaluates a row-level policy as a filter, not a gate that raises an error. When a policy denies a row, that row is simply excluded from the result, so a blocked read returns zero rows the same way a genuinely empty table would. Supabase points out the classic case: a policy of using (auth.uid() = user_id) silently fails for an unauthenticated request, because auth.uid() is null and null = user_id is always false in SQL. There is no exception, no status code on the read path, just an empty array. That is convenient for the database and dangerous for you, because the absence of an error is not the presence of safety.
What does a silent leak look like next to a silent lockout?
They are opposite mistakes with the same calm appearance. A silent lockout happens when RLS is on with a missing or too-strict policy, so legitimate users see nothing and the app looks broken. A silent leak happens when RLS is off or a policy is too permissive, so the data flows normally and nobody notices that anyone can read it. The table sets them side by side.
| Silent failure | Cause | What you see | Real risk |
|---|---|---|---|
| Silent lockout | RLS on, policy too strict, or null auth.uid() | Empty results, no error | App looks broken, tempting you to over-loosen the policy |
| Silent leak | RLS off, or a policy like using (true) | Data returns normally | Anyone with the anon key can read every row |
The trap that connects them is the fix for the first that creates the second: a developer loosens a policy to make the empty results go away, and turns a lockout into a leak.
Why does this matter more in a mobile app?
Because the anon key is not a secret, and your app proves it. Supabase publishes the anon key for client use, so it is embedded in your mobile bundle and anyone can extract it, point a tool at your project URL, and query the REST API directly. They never touch your app's interface, so any filtering you do in the UI is irrelevant. RLS is the only layer that decides what that raw API call returns. Supabase is explicit that RLS must always be enabled on any table in an exposed schema, which is the public schema by default, precisely because the key that reaches those tables is public.
How do you test RLS so the failures are not silent?
Make the database tell you what the API would return. Three checks cover most of it. First, run the Supabase Security Advisor, which flags tables in the public schema that have RLS disabled. Second, query each table with the anon key and with a test user, and confirm you can read your own rows and get an empty result for someone else's, rather than assuming. Third, write the policy to check authentication explicitly so an unauthenticated request is denied on purpose, not by accident:
alter table profiles enable row level security;
create policy "Users read only their own profile"
on profiles for select
to authenticated
using ( (select auth.uid()) = user_id );
For deeper coverage, Supabase points to testing policies with pgTAP so the rules are verified on every change rather than by hand.
What to watch out for
The first trap is reading empty results as proof of safety; they often mean the opposite, a policy you have not finished. The second is the separate-policy gotcha: a read needs a USING policy and a write needs a WITH CHECK policy, so an ALL policy can pass one path and silently block the other. The third is mobile-specific: because the anon key is in your binary, the only honest test is the one that uses it the way an attacker would. A pre-submission scan such as PTKD.com (https://ptkd.com) reads the compiled APK, AAB, or IPA, recovers the Supabase URL and anon key, and checks against OWASP MASVS whether your tables answer an unauthenticated request, which is exactly the silent leak this article is about. The limit is that a scan confirms exposure; you still write the policies that close it.
What to take away
- Supabase RLS fails silently: a denied query returns an empty result, so leaks and lockouts both look calm.
- A too-strict policy locks users out and a too-loose one leaks data, and fixing the first carelessly creates the second.
- In a mobile app the anon key is public in your bundle, so RLS is the only guard on the data your REST API returns.
- Test policies with the Security Advisor and as the anon role, and verify the build with a pre-submission scan such as PTKD.com that probes whether your RLS is actually leaking.



