July 2, 2026
Start from the fact that decides everything else: the anon key is public. It is in your bundle, it can be extracted in minutes, and treating it as a secret is the root of most Supabase data leaks.
RLS is not defence in depth. For a mobile app, it is the defence.
using (true) left behind from developmentIt is the fastest way to unblock yourself while building, and it is the easiest thing to forget. A select policy of using (true) on a table with user data means anyone with the anon key — which is everyone — can read every row.
Before shipping, list every policy and read it out loud as a sentence. "Anyone can read all payments" tends to get caught that way.
// This is a UX convenience. It is not a permission.
const { data } = await supabase.from('orders').select('*').eq('user_id', user.id);
If the policy is permissive, removing that .eq() returns everything. The filter runs on the client, and the client is the attacker's machine. The eq should be a redundant convenience on top of a policy that already restricts to auth.uid().
The service-role key bypasses every policy by design. That is correct behaviour and it is why the key must never reach the client — not in the app, not in a public environment variable, not in a NEXT_PUBLIC_ anything. If it needs to run, it runs in an Edge Function or on your server.
A related trap: auth.uid() is null on a service-role connection, so any helper function of the form is_admin() returns false there. Code that reads "only admins may do this" can therefore reject your own backend. Handle both cases explicitly.
A select policy controls which rows come back, not which columns. If one column holds something the client should never see — an internal cost, a signed asset URL, someone's email — a row policy will happily return it. Revoke the column and grant back the ones the client needs.
This one bit me: a table where every new column had to be granted explicitly, and adding one without the grant made the whole query 401 for anon users. Loud, at least.
Write one. Log in as user A, request user B's row, assert you get nothing. Run it in CI. Policies drift, migrations get reordered, and "we checked it once" is not a control.
I wire Supabase into mobile apps for a living, including the parts that decide who can read what. Get in touch if you would like a second pair of eyes on yours.