Users can see other users' data: fixing RLS and access rules
Everything works when you test with one account. Then someone finds they can read, change or delete other customers' rows through the public API, or give themselves admin at sign-up. This came up in 10 of the verified builder reports in our data, across Supabase, Firebase and hand-rolled backends.
What you'll see
- A signed-in user, or an anonymous visitor with only the public key, can list every row in a table instead of just their own.
- Changing an id in a request lets one user update or delete another customer's record.
- Anonymous requests can insert rows, or overwrite rows that belong to paying customers.
- A function or view the client can call returns other users' emails, tokens or password hashes.
- Someone signs up with a role field in their user metadata and gets admin access.
- Supabase Security Advisor reports 'Table publicly accessible' or 'Security policy relies on user-editable data'.
Why it happens
RLS is off on a table in an exposed schema
The project URL and publishable key ship in the browser, so row-level security is the only thing keeping one user out of another's rows. On Supabase, tables created in `public` get select, insert, update and delete grants for `anon` and `authenticated` by default. Without RLS, a table in an exposed schema is readable and writable by any role with a grant on it. Anyone with your project URL can read, edit and delete every row.
Policies that only check that someone is signed in
A policy of `using (true)`, or one that only checks that a user is signed in, passes every test you run with a single account. In production it gives every signed-in user access to every row. Firebase has the same trap: a rule that only checks `request.auth != null` gives any logged-in user read and write access to the whole database.
Writes without `with check`
For select and delete, `using` decides which existing rows are visible. For insert, `with check` decides which new rows are allowed. For update, `using` decides which rows can be changed and `with check` decides what the changed row may look like. Without `with check`, a user can insert a row stamped with someone else's `user_id`, or move an existing row to another owner.
Roles read from data the user controls
The signed-in user can update Supabase's `raw_user_meta_data` (the `options.data` you pass to `signUp`), so it is the wrong place for authorization data. If a policy or app check reads a role from `user_metadata`, from a claim the client sets, or from a profile column the user can update, anyone can make themselves an admin.
Security definer functions and views that skip RLS
A `security definer` function runs as the role that created it. If it sits in an exposed schema, anyone can call it over the Data API with the creator's privileges, bypassing your policies. Views also bypass RLS by default, because they are usually created by the `postgres` user.
How to fix it
Turn on RLS for every table in an exposed schema
With RLS on and no policies, the publishable key can reach no rows. Access stays denied until a policy allows it. Do this for every table in an exposed schema, including lookup and join tables.
alter table public.orders enable row level security;Write one policy per operation, tied to ownership
Postgres accepts only one operation per `for` clause, so write separate select, insert, update and delete policies. Name the role with `to`, and put `with check` on every write. Updates also need a matching select policy, or they match no rows.
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);Keep roles where users cannot write
Store roles in `raw_app_meta_data`, which the user cannot update, or in a roles table with access revoked from `anon` and `authenticated`. Add them to the token with a Custom Access Token Hook. Never read roles from `user_metadata`.
create policy "reports_admin_read" on public.reports for select to authenticated using ((select auth.jwt() -> 'app_metadata' ->> 'role') = 'admin');Close the paths that skip policies
Move `security definer` functions out of every schema listed under Exposed schemas. On Postgres 15 and later, set `security_invoker` on views so they follow the RLS of their underlying tables. Never use the secret or `service_role` key in the browser, because it bypasses RLS.
alter view public.order_summaries set (security_invoker = true);On Firebase, check ownership in your rules
Replace `if true` and `if request.auth != null` with ownership checks such as `request.auth.uid == resource.data.author_uid` for reads and deletes. For creates, check `request.resource.data` so a user cannot write a document that names someone else as its owner.
Test with two accounts and an anonymous client
Sign in as user A and create a row. Then, as user B and as an anonymous client, try to read, update and delete it. When RLS blocks an update or delete, the call returns no rows instead of an error, so assert on the rows that come back.
const { data: row } = await asA.from('orders') .insert({ user_id: idA, total: 10 }).select().single(); const { data: seen } = await asB.from('orders').select().eq('id', row.id); const { data: edited } = await asB.from('orders') .update({ total: 0 }).eq('id', row.id).select(); assert(seen.length === 0 && edited.length === 0);
Check it's fixed
- Supabase Security Advisor shows no 'Table publicly accessible', 'Security policy not enforced', 'Security policy relies on user-editable data' or 'View bypasses row-level security' warnings.
- As user B and as an anonymous client, reading, updating and deleting user A's row each returns zero rows, and an insert with A's `user_id` is refused.
- Signing up with `role: 'admin'` in `options.data` gives the new account no extra access.
- pgTAP tests created with `supabase test new` pass under `supabase test db`, and they assert on returned rows instead of using `lives_ok` for allowed writes.
Gemmein is a backend in beta with no policies to write. Each collection gets exactly one of seven plain-English rules (private, shared, public_read, community, addressed, direct, admin_write), enforced on Gemmein's servers. The app never implements authorization. There are no roles inside the app: every session reports `member`, the owner's included, so nothing a user sends at sign-up makes them an admin. A secret key refuses to run in a browser or a phone. The trade-off is deliberate. There are no custom roles, team workspaces or per-user record permissions. If you need SQL, migrations and full control, Supabase is the better choice.
Questions
Is it safe to ship the Supabase publishable (anon) key in the browser?
Yes, as long as RLS is on for every table in an exposed schema and your policies are correct. Without RLS, that key can read and write any table it has grants on. The secret or `service_role` key bypasses RLS and must never reach the browser.
Why does my update silently change nothing after I added RLS?
An update needs a matching select policy as well as an update policy. When RLS filters a row out, the update matches zero rows and returns no error, so check the rows that come back.
Is checking that the user is signed in enough?
No. A policy of `to authenticated using (true)`, or a Firebase rule of `request.auth != null`, lets every signed-in user reach every row. Compare the row's owner column with `auth.uid()` or `request.auth.uid`.
Where should I store user roles on Supabase?
Store them in `raw_app_meta_data` or in a roles table that `anon` and `authenticated` cannot access. If you need them in policies, add them to the JWT with a Custom Access Token Hook. The user can edit `raw_user_meta_data`, so never use it for authorization.
Sources
- Supabase: Row Level Security
- Supabase: Securing your API
- Supabase: Performance and Security Advisors
- Supabase: Custom Claims and Role-based Access Control
- Firebase: Avoid insecure rules
- OWASP Authorization Cheat Sheet