Supabase RLS Mistakes That Can Expose Your Application
Your Supabase key ships in every browser bundle, so Row Level Security decides what each request can touch. These are the policy mistakes common in Supabase apps, especially AI-generated ones, and how to verify policies before launch.
In this article
Most Supabase apps let the browser talk to the database directly. Your frontend calls Supabase's API with the project URL and a publishable key (or the legacy anon key), and that key sits in your JavaScript bundle where anyone can read it. That is by design: the key identifies your app, not your user. So the database itself has to decide, on every request, which rows the caller may see or change.
Row Level Security (RLS) is the Postgres feature that makes that decision. You attach policies to a table, and Postgres applies them to every query as if it had appended a WHERE clause. When the policies match your intent, a visible publishable key is expected. When they are wrong or missing, anyone with your project URL can read or modify data without touching your UI.
- Browser
- Supabase API
- Postgres policy
- Rows
The examples below use a projects table where each row belongs to one user.
RLS is authorization, not decoration
Supabase exposes tables in public, and any other schema you mark as exposed, through an auto-generated REST API. Your client code is one way to call it; curl with the publishable key is another. A .eq('owner_id', user.id) filter, a hidden admin button or a route guard is a convenience. An attacker drops the filter and calls the endpoint directly.
Postgres runs two checks before a request touches a row:
- Grants decide whether a role (
anon,authenticated,service_role) may run an operation on the table at all. A missing grant returns a42501error. - Policies decide which rows that operation applies to. A policy that matches nothing returns an empty result, not an error.
Per Supabase's Securing your API guide, new public tables on existing projects automatically get select, insert, update and delete grants for anon and authenticated, and Supabase is moving to opt-in grants. Check which behaviour your project has. Either way, adding policies does not remove grants.
Know what RLS never sees. Superusers and roles with BYPASSRLS skip policies, and table owners normally do too (Postgres row security docs). On Supabase that includes the service_role role behind secret keys and the postgres role the SQL editor uses. Views and security definer functions can inherit that bypass.
Forgetting to enable RLS
Tables created in the dashboard's Table Editor have RLS enabled by default. Tables created with SQL do not, whether the SQL came from the SQL editor, a migration, an ORM or an AI assistant. The Tables guide is explicit: if you create a table with SQL, enable RLS yourself.
alter table public.projects enable row level security;With RLS on and no policies, Postgres denies everything to publishable-key requests. That is safe, but it is where the next mistake starts: the app looks broken, and the quick fix is a policy that allows everything.
To find tables that were missed, query the catalog. pg_tables.rowsecurity mirrors pg_class.relrowsecurity:
select schemaname, tablename
from pg_tables
where schemaname = 'public'
and not rowsecurity
order by tablename;This should return zero rows; add any other exposed schemas to the filter. The dashboard's Security Advisor runs a similar check (rls_disabled_in_public) plus several others mentioned below.
Enable RLS, set grants and create policies in the same migration as the create table, so the table never exists unprotected. Review AI-generated migrations like any security-sensitive diff; the AI code review checklist covers what to look for. As a backstop, Supabase documents an event trigger that enables RLS on new tables.
Policies that are too broad
Enabling RLS and then writing using (true) gets you most of the way back to no RLS. Both of these are common in generated code:
-- No "to" clause: applies to every role, including anon
create policy "Enable read access for all users"
on public.projects for select
using (true);
-- Any signed-in user can edit any row
create policy "Authenticated users can update"
on public.projects for update to authenticated
using (true);Three details make these worse than they look:
- A missing
toclause meanspublic: every role, includinganon. to authenticatedmeans any signed-in user, not the row's owner. With open sign-ups, anyone can becomeauthenticatedin seconds, and anonymous sign-ins also get theauthenticatedrole.- Permissive policies combine with
OR(CREATE POLICY). If any policy allows a row, it is allowed. A broad policy added to fix one screen widens access everywhere.
using (true) is right for genuinely public data, such as published posts. Grant only select on those tables and name the roles. Everything else needs a condition tying the row to the caller. Storage follows the same rules through policies on storage.objects: an insert policy with with check (true) and no to clause lets any caller, signed in or not, upload to any bucket.
To review what you have:
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, cmd;Look hard at rows where qual or with_check is true, where roles is {public}, or where a table has several permissive policies for one command. The Security Advisor flags always-true and overlapping policies, but it cannot know your intent.
Confusing authentication with authorization
Authentication answers "who is this?". Authorization answers "may they do this to this row?". Many broken policies only answer the first: auth.uid() is not null, to authenticated using (true), or a server route that checks for a session and then trusts whatever ID the client sent.
A related mistake is authorizing on data the user controls. user_metadata (stored as raw_user_meta_data) can be changed by the signed-in user through supabase.auth.updateUser(); app_metadata cannot. So this policy lets any user make themselves an admin:
-- Don't: users can write their own user_metadata
create policy "Admins can read all projects"
on public.projects for select to authenticated
using ( (auth.jwt() -> 'user_metadata' ->> 'role') = 'admin' );Put roles in app_metadata, which only server code can set, or in a table clients cannot write. JWT claims are only as fresh as the token, so a role removed from app_metadata still appears in auth.jwt() until the token refreshes. A roles table checked at query time has no such lag.
The same applies on the server. Supabase's Next.js server-side auth guide says not to rely on the user object from getSession() for authorization, because it comes from client-controlled storage such as cookies. Use getClaims(), which verifies the JWT, or getUser(), which asks the Auth server.
User-owned rows
A complete setup where each row belongs to one user. First the table, grants and index:
create table public.projects (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null default auth.uid()
references auth.users (id) on delete cascade,
name text not null,
created_at timestamptz not null default now()
);
alter table public.projects enable row level security;
revoke all on table public.projects from anon, authenticated;
grant select, insert, update, delete on table public.projects to authenticated;
create index projects_owner_id_idx on public.projects (owner_id);Then one policy per operation:
create policy "Owners can read their projects"
on public.projects for select to authenticated
using ( (select auth.uid()) = owner_id );
create policy "Owners can create their projects"
on public.projects for insert to authenticated
with check ( (select auth.uid()) = owner_id );
create policy "Owners can update their projects"
on public.projects for update to authenticated
using ( (select auth.uid()) = owner_id )
with check ( (select auth.uid()) = owner_id );
create policy "Owners can delete their projects"
on public.projects for delete to authenticated
using ( (select auth.uid()) = owner_id );The details are deliberate:
(select auth.uid())lets Postgres evaluate the function once per statement (aninitPlan) instead of once per row. Supabase recommends this forauth.uid(),auth.jwt()andsecurity definerhelpers whose result doesn't depend on the row.auth.uid()is null for signed-out requests, so the comparison can't be true for them, andto authenticatedskips these policies foranonentirely.- Separate policies per command are easier to review than one
for allpolicy. - The
owner_idindex keeps policy checks from turning into sequential scans as the table grows. anonholds no grants, so signed-out requests fail before any policy runs.
Client queries should still filter with .eq('owner_id', userId); Supabase's RLS performance guide recommends it for better query plans. The policy is what enforces it.
UPDATE and INSERT conditions
USING and WITH CHECK answer different questions:
usingfilters existing rows: what aselectsees and what anupdateordeletecan target. Failing rows are skipped silently.with checkvalidates the new row aninsertorupdatewould write. A failing row raises an error and aborts the statement.
An insert policy only takes with check. An update policy takes both, and if with check is omitted, Postgres reuses using for the new row. So the dangerous version isn't a missing with check, it's a weaker one:
-- The owner can target their row, then give it to anyone
create policy "Owners can update"
on public.projects for update to authenticated
using ( (select auth.uid()) = owner_id )
with check ( true );How much this exposes depends on the table's other policies. When an update reads columns (a WHERE or RETURNING), Postgres also checks the new row against select policies, so a strict owner-only select policy often blocks the reassignment as a side effect. But tables with broader read policies, such as public profiles or team-visible records, let the update through: a user hands their row to someone else, planting content in another account or moving memberships and entitlements. Permissive with check expressions also combine with OR, so a second, looser update policy has the same effect. Don't rely on side effects; make with check state the rule.
An update also needs a matching select policy to find rows. If updates silently affect nothing, check that before loosening anything.
Columns are the other half. RLS works on rows: once a user may update a row, they may change every column they hold an update grant on. A policy cannot separate name from plan, credits or role. Options:
Move privileged fields to their own table that clients can read but not write, updated only by server code. This is usually cleanest, and it matches Supabase's guidance for roles.
Use column privileges, which make any update touching other columns fail with a permission error regardless of policies:
revoke update on table public.projects from authenticated;
grant update (name) on table public.projects to authenticated;Supabase's column-level security docs call this an advanced feature: each new column needs a decision, and clients that send unchanged read-only columns in update payloads will start failing.
Use a before update trigger that raises when a protected column changes. Make sure it still lets legitimate server-side jobs through, and test both paths.
Service-role key misuse
Publishable keys (sb_publishable_...) and the legacy anon JWT are meant to be public. Requests using them run as anon, or as authenticated with a user session, so RLS applies. Secret keys (sb_secret_...) and the legacy service_role JWT run as service_role, which has BYPASSRLS: no policy constrains a request made with a secret key alone. Supabase's API keys guide also notes that the legacy keys are being deprecated by the end of 2026.
- Never ship a secret key to a client: browser bundles, mobile and desktop apps, CLIs. Next.js inlines
NEXT_PUBLIC_variables into client JavaScript wherever client code references them, and Vite'sVITE_prefix behaves similarly, so secret keys never get those prefixes. - The browser block is not protection. Supabase rejects secret keys from browser user agents, but anyone who finds the key can use it from another HTTP client.
- Keep the admin client in a server-only module. In Next.js,
import "server-only"turns an accidental client import into a build error. - Never give the admin client a user session. Supabase documents that a request carrying a user's access token runs under that user's policies, even when the client was created with a secret key.
- Use a separate secret key per backend component, so one leak means one rotation.
import "server-only";
import { createClient } from "@supabase/supabase-js";
export const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SECRET_KEY!,
{ auth: { persistSession: false, autoRefreshToken: false } }
);If a secret key leaks, treat it as compromised; deleting it from the repository does not revoke it. Supabase's procedure: fix the cause, create a new secret key, deploy it everywhere, confirm nothing uses the old one, then delete the old key (irreversible). For a leaked legacy service_role key, move to a new secret key and deactivate the legacy keys (reversible). Then review what the key could reach.
Testing as multiple users
Policies fail quietly. A using clause that filters out a row returns an empty result, so a broken policy and a correct one can look identical in the UI. Test as specific users and assert on what each can and cannot do.
In SQL
Inside a transaction, switch to authenticated, set the JWT claims that auth.uid() and auth.jwt() read, run your checks, and roll back. This seeds data, so use a local stack (supabase start) or a non-production branch:
begin;
-- Seed as postgres (bypasses RLS)
insert into auth.users (id, email) values
('11111111-1111-1111-1111-111111111111', 'alice@example.test'),
('22222222-2222-2222-2222-222222222222', 'bob@example.test');
insert into public.projects (owner_id, name)
values ('11111111-1111-1111-1111-111111111111', 'Alice only');
-- Act as Bob
set local role authenticated;
select set_config('request.jwt.claims',
'{"sub":"22222222-2222-2222-2222-222222222222","role":"authenticated"}', true);
select count(*) from public.projects; -- expect 0
update public.projects set name = 'Bob was here' returning id; -- expect 0 rows
-- Expect ERROR 42501; the error aborts the transaction
insert into public.projects (owner_id, name)
values ('11111111-1111-1111-1111-111111111111', 'Planted by Bob');
rollback;set_config(..., true) is equivalent to set local. Supabase's docs use set local request.jwt.claim.sub = '...', which auth.uid() also reads; setting the full request.jwt.claims JSON covers auth.jwt() policies too. Repeat as anon, and as Alice to confirm she keeps her access.
| Denied by | What you see |
|---|---|
| A missing grant | Error 42501 |
A with check violation | Error 42501 |
A using clause | No error, zero rows |
That last row is why "no error" proves nothing. Use returning and assert on what came back.
For repeatable tests, the Supabase CLI runs pgTAP files from supabase/tests/ (supabase test new projects_rls.test, then supabase test db). The RLS guide's policy tests section has a full example covering every operation for anon, the owner and a second user. Run it in CI.
In the app
SQL tests prove the policies; app tests prove the whole path, including your server routes and storage. Create two real accounts in staging, sign in as Bob with the publishable key, and go after Alice's row by ID:
import { createClient } from "@supabase/supabase-js";
const bob = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_PUBLISHABLE_KEY!, {
auth: { persistSession: false },
});
const { error } = await bob.auth.signInWithPassword({
email: "bob@example.test",
password: process.env.BOB_PASSWORD!,
});
if (error) throw error; // a failed sign-in would make every check below pass as anon
const aliceId = process.env.ALICE_PROJECT_ID!;
const read = await bob.from("projects").select("id").eq("id", aliceId);
const write = await bob.from("projects").update({ name: "Bob" }).eq("id", aliceId).select();
// expect read.data and write.data to both equal []Also assert that Alice's row is unchanged afterwards. An empty result shows Bob's request matched nothing; reading the row back as Alice shows it really was left alone.
Then call the API as an attacker would, with only the publishable key:
curl "https://YOUR_PROJECT_REF.supabase.co/rest/v1/projects?select=*" \
-H "apikey: $SUPABASE_PUBLISHABLE_KEY"With the setup above this should fail with a permission error, since anon has no grant. Repeat for every table, bucket and server route that accepts an ID from the client.
Admin/server access
Admin features have to step outside per-user policies somewhere. The safest place is server code that verifies the caller, checks their role against a trusted source, and only then uses the secret key for one operation:
import { createClient } from "@/lib/supabase/server"; // cookie-based, user-scoped
import { supabaseAdmin } from "@/lib/supabase/admin";
export async function POST(request: Request) {
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
const userId = data?.claims.sub;
if (!userId) return new Response("Unauthorized", { status: 401 });
const { data: role } = await supabase
.from("user_roles").select("role").eq("user_id", userId).maybeSingle();
if (role?.role !== "admin") return new Response("Forbidden", { status: 403 });
const { projectId } = await request.json(); // validate in real code
const { error } = await supabaseAdmin.from("projects").delete().eq("id", projectId);
return error ? new Response("Failed", { status: 500 }) : Response.json({ ok: true });
}user_roles needs RLS that lets users read their own row and gives clients no way to write it.
Inside the database, two objects commonly bypass RLS by accident.
Views. By default a view checks policies as its owner, usually postgres on Supabase, so a view over projects can return every row to anyone with a grant on it. On Postgres 15 and later, set security_invoker so the caller's policies apply (CREATE VIEW):
create view public.project_summaries
with (security_invoker = true)
as select id, name, created_at from public.projects;On older versions, revoke anon and authenticated access or move the view to an unexposed schema. Materialized views can't use RLS at all, so revoke select on them from both roles. This lists both kinds in public; a view (v) whose reloptions lacks security_invoker=true or security_invoker=on runs as its owner:
select c.relname, c.relkind, c.reloptions
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind in ('v', 'm');security definer functions run with their owner's privileges, and one owned by postgres skips RLS. The Supabase docs use them legitimately for role checks and recursive policies, but a function in an exposed schema is callable at /rest/v1/rpc/function_name by any role with EXECUTE, which Postgres grants to public by default. Keep them in an unexposed schema such as private, set search_path = '' with schema-qualified names, and revoke EXECUTE from public and anon unless intended. To list the ones in public:
select p.proname
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prosecdef;The Security Advisor reports these as security_definer_view, materialized_view_in_api, anon_security_definer_function_executable and authenticated_security_definer_function_executable. Treat each finding as a question to answer, not a warning to dismiss.
Production verification checklist
Run read-only checks against production and anything that writes against staging. For the rest of the launch surface, see the production checklist for vibe-coded applications.
- The
pg_tablesquery returns no exposed table withrowsecurity = false. - Every migration that creates a table enables RLS, sets grants and creates policies in the same file.
-
anonandauthenticatedhold only the grants the app needs (checkinformation_schema.role_table_grants). - Every policy has a
toclause; none apply topublic. - Every
using (true)orwith check (true)policy covers intentionally public data only. - Overlapping permissive policies on one table and command have been reviewed as a combined
OR. - Every update policy's
with checkis at least as strict as itsusing. - A test proves a user cannot reassign
owner_idto someone else. - Fields like
plan,creditsandroleare unwritable by clients, via a separate table, column privileges or a trigger. - No policy or server check uses
user_metadatafor authorization. - Policies use
(select auth.uid()), and filtered columns are indexed. - Every exposed view uses
security_invoker = trueor is unreadable byanonandauthenticated. - No exposed materialized view is selectable by
anonorauthenticated. - Every
security definerfunction setssearch_path = '', lives outside exposed schemas, and hasEXECUTErevoked where unintended. - Storage policies scope each bucket by operation and owner or folder; public buckets hold only public files.
- Built client assets (for example
.next/static) contain nosb_secret_string or legacyservice_rolekey. - No secret key sits in a
NEXT_PUBLIC_,VITE_or similar variable in any environment, including previews. - Every server route using the secret key verifies the caller with
getClaims()orgetUser()and checks their role first. - pgTAP tests cover allow and deny per operation for
anon, the owner and a second user, and run in CI. - With two real staging accounts, user B cannot read, update or delete user A's rows by ID.
- A REST call with only the publishable key returns nothing private.
- The Security Advisor shows no unresolved errors or warnings; dismissed findings have a written reason.
- Legacy keys are deactivated once unused, and you have rotated a secret key in staging at least once.
Rerun this list whenever a migration touches tables, views, functions or grants.
Keep learning
Free The Production Checklist for Vibe-Coded Applications
A practical checklist for reviewing AI-assisted applications before putting real users, data or money behind them.
GuideWebIntermediate
FreeReadFree Frontend Developer Roadmap 2026
A step-by-step path from your first web page to job-ready frontend work: HTML and CSS, JavaScript, Git, React, TypeScript, testing, accessibility, performance, deployment and responsible use of AI coding tools. Each step has a project to build and free official resources.
RoadmapWebBeginner
FreeView roadmapFree How to Review AI-Generated Code Before Shipping It
A practical process for reviewing AI-generated diffs for behaviour, security and maintainability before they reach production: what to read first, what to distrust, and what to ask the agent.
GuideWebIntermediate
FreeRead