Mistake 1: RLS was never switched on
A table created through a migration or the dashboard's SQL editor does not always have RLS enabled. Supabase's guidance is to enable it on every table in an exposed schema. Without it, the public key can reach the table through the REST API.
create table public.notes (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id),
body text
);
alter table public.notes enable row level security;
create policy "Read own notes"
on public.notes for select
to authenticated
using ((select auth.uid()) = user_id);Mistake 2: a policy that says true
The classic quick fix. A query returns nothing, the assistant sees a permission problem, and writes a policy that lets everyone through. Supabase's docs note that using (true) for the anon role gives every unauthenticated visitor read access, which is only acceptable for data you would publish on a public page anyway.
create policy "Anyone can read"
on public.orders for select
using (true);
create policy "Users read their own orders"
on public.orders for select
to authenticated
using ((select auth.uid()) = user_id);Mistake 3: writes with no per-row check
Insert policies only have a WITH CHECK clause, since there is no existing row to test. If that check is true, or checks only that the user is logged in, any signed-in user can create rows attributed to someone else.
For updates, PostgreSQL reuses the USING expression as the check if you omit WITH CHECK. That is fine when USING is tight, but it means you cannot express a rule about what the new row may look like. Say a user may edit their own order only while it is pending, and must not be able to mark it paid. Spell out both clauses.
create policy "Create own orders"
on public.orders for insert
to authenticated
with check ((select auth.uid()) = user_id and status = 'pending');
create policy "Edit own pending orders"
on public.orders for update
to authenticated
using ((select auth.uid()) = user_id and status = 'pending')
with check ((select auth.uid()) = user_id and status = 'pending');Mistake 4: the policy applies to the wrong role
If you leave out the TO clause, PostgreSQL applies the policy to PUBLIC, meaning every role, including anon. A policy meant for signed-in users then also runs for logged-out visitors. Usually auth.uid() is null for them and the comparison fails safely, but you should not rely on a comparison happening to fail. Name the role you mean.
The reverse also happens: a policy written to authenticated for something that should be public breaks the logged-out experience, and the quick repair is to loosen it to everything. Decide the audience first, then write the policy.
create policy "Read profiles"
on public.profiles for select
to authenticated
using (true);
create policy "Public product catalog"
on public.products for select
to anon, authenticated
using (is_published);Mistake 5: views that bypass RLS
By default, a PostgreSQL view checks access to its underlying tables using the permissions of the view owner, not the caller. The owner is usually a powerful role, so a view over an RLS-protected table can hand its rows to anyone who can query the view. Supabase's documentation recommends security_invoker = true on Postgres 15 and later, which makes the view use the caller's permissions and so respect RLS.
On older Postgres versions, the docs suggest revoking access to the view from anon and authenticated, or keeping it in a schema that is not exposed through the API.
create view public.my_orders
with (security_invoker = true)
as select id, status, total
from public.orders;
alter view public.existing_view set (security_invoker = true);Mistake 6: storage policies that trust anything
Files live in storage.objects, and RLS applies there as well. Supabase notes that uploading needs only an INSERT policy, and that upserts additionally need SELECT and UPDATE. A policy that checks only the bucket name lets any signed-in user write anywhere in it, including over other people's files.
Public buckets are a separate matter. Per the docs they are already publicly accessible, so files placed there are readable by anyone with the URL regardless of table policies. Put invoices, IDs and medical or legal documents in private buckets.
create policy "Upload to own folder"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);
create policy "Read own files"
on storage.objects for select
to authenticated
using (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.uid()::text)
);The one that defeats all six: the service role in the client
Supabase's secret key and the legacy service_role key authorize a Postgres role with the bypassrls privilege. Every policy above is ignored for requests that carry it. The docs say to use it only in backend components and never in browsers, public documents or bundled apps.
It appears in client code when an assistant hits an RLS error, wants the error gone, and swaps in the more powerful key. If you find it in a frontend file or a NEXT_PUBLIC_ or VITE_ variable, treat it as leaked: rotate it and move the operation to a server route.
A quick audit you can run today
- 1List public tables with rowsecurity set to false in pg_tables and decide for each whether RLS should be on.
- 2Query pg_policies for qual or with_check equal to true and justify every result or replace it.
- 3Check every policy has a TO clause, and read the roles column in pg_policies.
- 4Find views over private tables and set security_invoker where appropriate.
- 5Review each storage bucket: public or private, and which policies exist on storage.objects.
- 6Search your repo and built bundle for service_role or your secret key prefix.
Frequently asked questions
What is the most common Supabase RLS mistake?
Leaving RLS off on a table in an exposed schema, or turning it on and then adding a policy with using (true). Both leave the table open to anyone holding the public key. Check pg_tables for rowsecurity and pg_policies for unconditional conditions before every release.
Do I need WITH CHECK on update policies?
If you omit it, PostgreSQL reuses the USING expression for the check, which is safe when USING is tight. Write an explicit WITH CHECK when the new row must satisfy a different rule, such as preventing a user from changing a status or ownership column.
Do views respect Row Level Security in Supabase?
Not by default. A view uses its owner's permissions, which can bypass RLS on the underlying tables. On Postgres 15 and later, create the view with security_invoker = true so it uses the caller's permissions and their policies apply.
Does the service role key ignore RLS?
Yes. Supabase documents that secret and service_role keys bypass Row Level Security entirely. Use them only in server code such as backend APIs or Edge Functions, never in browser code, mobile bundles, public repositories or client-side environment variables.