Troubleshooting guide

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.

13 min read Updated 25 Sep 2026Last reviewed 25 Oct 2026How we collect cases

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

Cause 1

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.

Cause 2

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.

Cause 3

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.

Cause 4

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.

Cause 5

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

  1. 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;
  2. 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);
  3. 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');
  4. 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);
  5. 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.

  6. 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.

Specific fixes

Stuck on one exact error? These pages go deeper on one symptom each.

Fix it with Gemmein

On Gemmein, each collection has exactly one safety rule, and under private each signed-in user sees and edits only their own records, while a session touching a record it can't see gets 404 not_found. There is no in-app role to branch on, because every session reports member, and a write that sends role or another server-managed field is refused with 403 forbidden.

  1. Give personal data the private rule

    private means each signed-in user sees and edits only their own records, while shared lets every signed-in user read every record, which the docs call wrong for personal data. Before your app writes to a collection, declare it in gemmein/collections/<name>.json with its rule; npx gemmein sync creates it in the cloud app's dev environment from that local declaration.

    // gemmein/collections/orders.json
    { "rule": "private", "means": "each customer sees only their own orders" }
  2. Expect 404 not_found on records you can't see

    Touching a record your session can't see returns 404 not_found, not a 403, so its existence isn't leaked, and a non-author write in a shared collection also comes back 404. A real 403 comes back as forbidden and names a rule problem, and the docs say not to retry it.

  3. Keep roles out of record data

    There is no role to branch on, because every session reports member, and if your code sends id, userId, role or another server-managed field the answer is 403 forbidden, naming the field. The Gemmein dashboard is the admin dashboard: every customer, every record, every message, suspensions and refunds.

  4. Put an in-app admin screen on the server

    If your human asks for an admin screen inside the app, ask for a server key scoped read-only to just the collections the screen shows (npx gemmein keys create --access <c>:read,…, or the dashboard's API keys page); it lives in your server's environment, and the route verifies the caller and checks the admin list of person ids your human sets (e.g. ADMIN_PERSON_IDS). gemmeinServer() refuses to run in a browser or a phone (secret_key_in_client), npx gemmein check flags a build whose output carries a secret key, and npx gemmein go-live refuses it.

    // server only
    import { gemmeinServer } from "@gemmein/sdk";
    const g = gemmeinServer(process.env.GEMMEIN_ADMIN_READ_KEY);
    const admins = new Set((process.env.ADMIN_PERSON_IDS ?? "").split(","));
    export async function GET(req) {
      const token = (req.headers.get("authorization") ?? "").replace("Bearer ", "");
      const { person } = await g.verifySession(token);
      if (!admins.has(person.id)) return new Response("not found", { status: 404 });
      const { records } = await g.collection("orders").list({ limit: 50 });
      return Response.json(records);
    }
Read the docs: docs.gemmein.com/data →The rules have no team, group or workspace scope and no per-user record visibility beyond followers-only and circles, so records visible to some signed-in users but not others, chosen any other way, aren't supported yet.

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

Last reviewed 25 Oct 2026. What changed: No changes: steps re-checked against 6 official page(s). Checked against: supabase.com, supabase.com, supabase.com, supabase.com, firebase.google.com, cheatsheetseries.owasp.org. How we check this

← Back to the full report