7 Supabase RLS Mistakes That Leak Data in Expo Apps (and the SQL to Fix Each One)
A practical Row Level Security audit for React Native apps that talk to Supabase directly

Supabase lets an Expo app talk to Postgres directly. There's no API server in between, and the project URL and publishable key ship inside your app bundle. That's fine, by design. It's also why Row Level Security (RLS) is the only thing standing between any user of your app and every row in your database.
Most RLS problems aren't exotic. They're the same handful of mistakes, often copied from a quickstart or generated by an AI coding agent that just wanted the query to stop returning an empty array. This article walks through seven of them, how to spot each one, and the SQL that fixes it.
The examples use a simple todos table:
create table public.todos (
id bigint generated always as identity primary key,
user_id uuid not null references auth.users (id),
title text not null,
done boolean not null default false
);
Mistake 1: RLS was never turned on
Postgres doesn't enable RLS on new tables by default. If a table was created through a SQL migration, by you or by an agent, it may have no RLS at all. In that case, anyone holding your publishable key can read and write the entire table through the auto-generated REST API.
Find every table in public without RLS:
select tablename
from pg_tables
where schemaname = 'public'
and rowsecurity = false;
Fix:
alter table public.todos enable row level security;
With RLS on and no policies, the table returns nothing to client requests. That's the safe default: deny everything, then add policies for exactly what each role needs. The Security Advisor in the Supabase dashboard also flags tables in exposed schemas that have RLS disabled, so check it after every migration.
Mistake 2: using (true) policies
This shows up a lot, because it makes errors go away:
-- "it works now"
create policy "Enable read access for all users"
on public.todos for select
using (true);
using (true) means every row is visible to everyone, including signed-out users. It's correct for genuinely public data, like a product catalogue. For anything user-owned, scope it to the signed-in user:
create policy "Users can read their own todos"
on public.todos for select
to authenticated
using ((select auth.uid()) = user_id);
Two details matter here:
to authenticatedstops the policy from being evaluated for anonymous requests at all.(select auth.uid())instead of bareauth.uid()lets Postgres evaluate the function once per statement instead of once per row. The Supabase RLS docs recommend this for performance on larger tables.
Mistake 3: insert and update policies that don't check ownership
A read policy is only half the job. This one looks harmless:
create policy "Users can create todos"
on public.todos for insert
to authenticated
with check (true);
But it lets any signed-in user insert rows with someone else's user_id. Depending on your app, that might mean writing into another user's feed, list or inbox. Always tie with check to the caller:
create policy "Users can create their own todos"
on public.todos for insert
to authenticated
with check ((select auth.uid()) = user_id);
create policy "Users can update their own todos"
on public.todos for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
On update, using decides which rows you can target. with check decides what the row is allowed to look like afterwards. Without the second one, a user could update their own row and reassign it to another user.
Mistake 4: authorization based on user_metadata
It's tempting to store a role in user metadata at sign-up and check it in a policy:
-- don't do this
using ((auth.jwt() -> 'user_metadata' ->> 'role') = 'admin')
The problem is that users can edit their own user_metadata from the client:
await supabase.auth.updateUser({ data: { role: 'admin' } });
Supabase's docs warn about exactly this. Keep authorization data in app_metadata, which only the server side can change, or in your own roles table protected by RLS:
using ((auth.jwt() -> 'app_metadata' ->> 'role') = 'admin')
Mistake 5: views and security definer functions that skip RLS
You can lock down every table and still leak data through a view. Views are usually created by the postgres role, and by default they run with their creator's permissions, which means they bypass RLS.
On Postgres 15 and later, make the view respect the caller's policies:
create view public.todo_stats
with (security_invoker = true) as
select user_id, count(*) as total
from public.todos
group by user_id;
The same applies to functions marked security definer, which also run as their creator. If a function like that lives in an exposed schema, any client can call it over RPC. Keep them in a schema that isn't exposed through the API, set search_path = '', and check auth.uid() inside the function if it touches user data.
Mistake 6: the secret key ended up in the app
The secret key (formerly the service_role key) bypasses RLS entirely. It belongs on a server or in an Edge Function, never in an Expo app.
The easy way to leak it is an environment variable with the wrong prefix:
# .env
EXPO_PUBLIC_SUPABASE_URL=https://xyz.supabase.co
EXPO_PUBLIC_SUPABASE_KEY=sb_publishable_... # fine
EXPO_PUBLIC_SUPABASE_SECRET=sb_secret_... # leaked to every user
Expo inlines every EXPO_PUBLIC_ variable into the JavaScript bundle, and the Expo docs are explicit that these values are visible in plain text in the compiled app. Anyone can unzip an APK and read them. If a secret key has ever shipped in a build, rotate it.
Anything that genuinely needs elevated access, such as sending email, calling a paid AI API or processing payments, should go through an Edge Function. The app sends the user's session, and the function holds the secret.
Mistake 7: forgetting Storage
Files in Supabase Storage live in the storage.objects table, and they're protected by RLS too. They're easy to forget because they don't show up next to your other tables. A public bucket serves every file to anyone with the URL. A private bucket with a loose policy isn't much better.
Scope file access the same way as rows, for example by putting each user's files in a folder named after their user ID:
create policy "Users can read their own avatar files"
on storage.objects for select
to authenticated
using (
bucket_id = 'avatars'
and (storage.foldername(name))[1] = (select auth.uid())::text
);
How to test your policies
Don't trust a policy you haven't tried to break. In the SQL editor, impersonate a user inside a transaction and check what comes back:
begin;
set local role authenticated;
set local request.jwt.claim.sub = '00000000-0000-0000-0000-000000000001';
select * from public.todos; -- only this user's rows
insert into public.todos (user_id, title)
values ('00000000-0000-0000-0000-000000000002', 'not mine'); -- should fail
rollback;
Then do the same from the app itself. Sign in as user B, request one of user A's rows by id, and confirm you get nothing back. For anything long-lived, turn these checks into pgTAP tests and run them with supabase test db in CI, so a future migration can't quietly undo them.
Starting from a secure baseline
If you're starting a new Expo + Supabase app, it's much easier to begin with RLS on every table than to retrofit it later. That's the approach we took with AppLighter. Its starter apps ship with RLS policies on every table, and secrets stay in Edge Functions rather than the client bundle. They also include an /audit-security slash command your coding agent can run to look for the problems above. Whatever you start from, the checklist below still applies.
RLS checklist for every migration
- RLS is enabled on every table in an exposed schema
- No
using (true)orwith check (true)on user-owned data - Insert and update policies check
auth.uid()against the owner column - Authorization reads
app_metadataor a roles table, neveruser_metadata - Views use
security_invoker = true; security definer functions are kept out of exposed schemas - Only the publishable key is in
EXPO_PUBLIC_variables - Storage buckets have policies scoped per user
- Policies are tested by impersonating a user, not just by using the app
RLS is one of the best parts of Supabase. It just doesn't protect anything until you write the policies yourself.
Which of these have you run into in a real project? Let me know in the comments.

