All checks

Supabase RLS misconfiguration

Severity: High – CriticalQuick + Deep· 6 min read

What it is

Supabase gives every project a public "anon" key so your frontend can talk to the database directly. That's safe only because of Row Level Security (RLS), a Postgres feature that decides, row by row, what each request may read or change. An RLS misconfiguration is a table where that protection is off, or where a policy lets everyone through.

Think of the anon key as the front door of an apartment building: it's meant to be shared. RLS is the lock on each apartment. Turn it off and the front-door key opens every apartment.

Why it matters

With RLS off, anyone who has your anon key can read, and often change, every row in the table. The anon key is in your frontend code by design, so "anyone" means every visitor to your site.

That turns a configuration slip into a full data exposure: user profiles, orders, private messages, whatever the table holds. And because the requests go straight to Supabase's API, they never pass through your own backend, so your app's logs won't show them.

Supabase's own documentation is direct about it: RLS must always be enabled on tables in an exposed schema, and the service_role key, which bypasses RLS entirely, must never be used in the browser.

How it happens

The common causes:

  • A table created in SQL without RLS. Tables made through the Supabase dashboard get RLS turned on by default; tables created by hand-written migrations don't, unless you add the statement.
  • RLS switched off to "make it work" while debugging, then shipped.
  • A policy that allows everything, such as using (true), which is RLS in name only.
  • A public storage bucket holding files that shouldn't be public.
-- supabase/migrations/20260101_orders.sqlcreate table public.orders (  id bigint generated always as identity primary key,  user_id uuid references auth.users not null,  total numeric not null,  address text);-- No "enable row level security": the anon key can read every order.

A policy can be just as open as having no RLS at all:

SQL
alter table public.orders enable row level security; -- Looks protected, but "true" matches every row for every user.create policy "read orders" on public.orders  for select using (true);

How it's abused

At a conceptual level:

  1. Open the site and read the Supabase URL and anon key from the JavaScript, where they're meant to be.
  2. Use the standard Supabase client or REST API to list tables and request rows.
  3. Any table without RLS, or with an always-true policy, returns its data, and may accept inserts, updates and deletes too.

There's nothing to break into. The attacker uses your project's public API the same way your frontend does.

How to fix it

Enable RLS on every table in the public schema, then add policies that tie rows to the signed-in user.

create table public.orders (  id bigint generated always as identity primary key,  user_id uuid references auth.users not null,  total numeric not null,  address text); alter table public.orders enable row level security; create policy "users read their own orders" on public.orders  for select to authenticated  using ((select auth.uid()) = user_id); create policy "users create their own orders" on public.orders  for insert to authenticated  with check ((select auth.uid()) = user_id);

Why this works: with RLS on and no matching policy, Postgres returns nothing. Every rule you add is an explicit decision about who can do what. auth.uid() comes from the signed-in user's token, which the client can't forge.

More good practice:

  • Write policies per action. Select, insert, update and delete each need their own decision. An update policy usually needs both using and with check.
  • Keep service_role on the server. Use it only in server code or Edge Functions, from a variable without a public prefix.
  • Make buckets private unless every file in them is meant for the public, and use signed URLs for the rest.
  • Test as another user. Sign in as user B and try to read user A's rows; you should get nothing.

Common mistakes

  • Enabling RLS but leaving a using (true) policy. That's the same as no protection for that action.
  • A select policy but no insert or update policy decisions. Each action is separate: think about who may write, not only who may read.
  • Trusting a column the client can set. A policy that checks user_id = auth.uid() is only safe on insert if with check stops users writing someone else's user_id.
  • Using the service_role key "just for this one query" in the browser. It bypasses every policy for everyone who downloads it.

Testing your fix

Sign in to your app as a normal user and, from the browser console, use the Supabase client to select from each table without a filter. You should see only your own rows, or none. Try an insert with another user's ID and an update of someone else's row; both should fail. Repeat while signed out, which tests what the bare anon key can do.

How SetRepo detects it

These checks read your repository's migrations and config on every Quick and Deep scan; no access to your Supabase project is needed.

  • RLS turned off: a migration disables RLS on a table, or creates a public table and never enables it.
  • Open policies: a policy whose using or with check is just true. Open write policies are reported as definite. Open read policies are labeled "verify", since some tables are public on purpose.
  • Public storage buckets: buckets created or configured as public, labeled "verify".
  • service_role in client code: the key in a public-prefixed variable, in browser or mobile code, or as a JWT outside server code.
  • Live check: when you add your site's URL and verify the domain, SetRepo also tries a one-row read of each table with your public anon key, to show which ones anyone can read. It never writes, and keeps none of the data.

Deep scans add the AI review of access control, which reads your RLS statements alongside the code that queries those tables.

Checklist

  • RLS is enabled on every table in the public schema.
  • Every table has policies for the actions it should allow, and nothing more.
  • No policy uses a bare true unless the data is meant to be public.
  • Update policies have both using and with check.
  • The service_role key is only used on the server.
  • Storage buckets are private unless every file is meant to be public.
  • You've tested reading another user's rows and got nothing back.

FAQ

Is the anon key a secret?

No. It's meant to be in your frontend. It's safe exactly as far as your RLS policies are correct.

I enabled RLS and now my app returns nothing. What happened?

That's RLS working: with no policy, nothing is allowed. Add a policy for each action your app needs, scoped to the signed-in user.

Do I need RLS if all my queries go through my own server?

If your server uses the service_role key, RLS is bypassed there anyway. But tables in an exposed schema are still reachable with the anon key from anywhere, so enable RLS on them regardless.

Does RLS slow my queries down?

Policies add conditions to queries, so they can affect performance on large tables. Index the columns your policies filter on (usually user_id), and wrap auth.uid() in a select as in the example, which lets Postgres evaluate it once per query instead of once per row.

References

Check your app for this, free.

Start free scan