How to Secure a Supabase App Before Launch: RLS Checklist (2026)
This guide is for anyone about to launch an app on Supabase, including apps generated by Lovable, Bolt or another AI builder, who wants to be sure a stranger with the publishable key cannot read or rewrite the database. By the end you will have RLS on every exposed table, policies tied to auth.uid(), deliberate grants that survive Supabase's 30 October 2026 change, secret keys kept off the client, private storage buckets, Edge Functions that check who is calling, and a repeatable test as an anonymous visitor and as a second user. Current versions: Supabase CLI v2.122.0 and supabase-js v2.118.0. The SQL outputs below are real: I ran them on PostgreSQL 16.8 locally with stand-in anon, authenticated and service_role roles and a minimal auth.uid(), which reproduces how the Supabase RLS docs describe policy evaluation (it is not the full Supabase stack). Prerequisites: a Supabase project you own, the SQL editor or psql, and the Supabase CLI. The companion explainer on vibe-coded app security covers why this matters, with the incidents.
Step 1: Find every table without RLS
Start with an inventory. Anything in the public schema is reachable through the Data API with the publishable key unless grants or RLS stop it. Run this in the SQL editor:
-- Tables in public and whether RLS is enabled
select c.relname as table_name, c.relrowsecurity as rls_enabled
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind in ('r', 'p')
order by 1;
On my test database it showed one table done and one forgotten:
table_name | rls_enabled
------------+-------------
notes | t
orders | f
Then run the Security Advisor, which is the open-source Splinter linter. In the dashboard it lives under Advisors. From the CLI, against your linked project:
# Supabase CLI v2.x; link once with: supabase link --project-ref YOUR_REF
supabase db advisors --linked --type security --level warn
The lints that matter most before launch are rls_disabled_in_public, rls_enabled_no_policy, policy_exists_rls_disabled, rls_policy_always_true, rls_references_user_metadata, security_definer_view, sensitive_columns_exposed and public_bucket_allows_listing. Fix every error and every security warning.
Step 2: Enable RLS and grant access deliberately
Enabling RLS with no policy is the safe starting state: the role sees zero rows. Here is the difference for an anonymous request, before and after:
-- As anon, RLS off: every row comes back
set role anon; select user_id, body from public.notes; reset role;
-- user_id | body
-- 11111111-1111-1111-1111-111111111111 | alice secret
-- 22222222-2222-2222-2222-222222222222 | bob secret
alter table public.notes enable row level security;
-- As anon, RLS on, no policy yet
set role anon; select count(*) from public.notes; reset role;
-- count
-- 0
Grants are the second layer, and they changed this week. Supabase's breaking change on Data API exposure stopped auto-granting new public tables to anon, authenticated and service_role. It began rolling out as the default for new projects on 30 May 2026 and was scheduled to reach all existing projects on 30 October 2026. Existing tables keep their grants. New ones return 42501 permission denied until you grant. Grants are checked before RLS: grants decide whether a role can touch the table, policies decide which rows.
So every new table migration should carry all three pieces together:
-- supabase/migrations/20261031120000_orders.sql
create table public.orders (
id bigint generated always as identity primary key,
user_id uuid not null references auth.users (id) on delete cascade,
total_cents integer not null,
status text not null default 'pending'
);
-- 1. Grants: only what each role needs. anon gets nothing here.
grant select, insert on public.orders to authenticated;
grant select, insert, update, delete on public.orders to service_role;
-- 2. RLS
alter table public.orders enable row level security;
-- 3. Index the column policies filter on
create index orders_user_id_idx on public.orders using btree (user_id);
Notice that authenticated gets no update. Order status should be written by your payment webhook, not by the browser. For older tables that still carry the blanket default grants, revoke and re-grant explicitly, as the RLS docs recommend:
revoke all on table public.orders from anon, authenticated;
grant select, insert on table public.orders to authenticated;
Because migrations and AI tools can create tables outside the dashboard (where RLS is not switched on for you), add an event trigger that enables RLS on any new public table. Supabase's 2025 security retro recommends this approach. This version worked on my test database:
create schema if not exists private;
create or replace function private.rls_auto_enable()
returns event_trigger
language plpgsql
security definer
set search_path = ''
as $$
declare
cmd record;
begin
for cmd in
select * from pg_event_trigger_ddl_commands()
where command_tag in ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
and object_type in ('table', 'partitioned table')
and schema_name = 'public'
loop
execute format('alter table %s enable row level security', cmd.object_identity);
raise notice 'RLS enabled on %', cmd.object_identity;
end loop;
end;
$$;
create event trigger ensure_rls_on_public
on ddl_command_end
when tag in ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
execute function private.rls_auto_enable();
create table public.payments (id bigint primary key, user_id uuid, status text);
NOTICE: RLS enabled on public.payments
CREATE TABLE
Step 3: Write policies with auth.uid()
Each policy should name its role with to authenticated (so it never runs for anon), and compare an owner column to (select auth.uid()). Wrapping the call in select lets Postgres evaluate it once per statement instead of once per row, which the docs flag as the main RLS performance fix.
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);
using filters which existing rows a statement can see. with check validates the row being written. When a signed-in user tries to insert a row for someone else, the database refuses:
select body from public.notes; -- as user 1111...
body
--------------
alice secret
(1 row)
insert into public.notes (user_id, body) values ('22222222-...', 'forged');
ERROR: new row violates row-level security policy for table "notes"
Updates need both clauses and a matching select policy. Without the select policy the update silently matches nothing. My test, with only an update policy on profiles, returned UPDATE 0; after adding a select policy for the same owner, the identical statement returned UPDATE 1. No error either way, which is why this one wastes afternoons.
create policy "profiles_select_own" on public.profiles
for select to authenticated using ((select auth.uid()) = id);
create policy "profiles_update_own" on public.profiles
for update to authenticated
using ((select auth.uid()) = id)
with check ((select auth.uid()) = id); -- stops reassigning the row to another user
For team or organisation access, the pattern scales to membership tables; my Postgres multi-tenant RLS guide covers that in depth.
Step 4: Close the quiet bypasses
Three things get around otherwise correct policies.
Views. A view runs as its owner by default, so it ignores the caller's RLS. My test view over notes returned both users' rows to anon until I set security_invoker, after which it returned zero. On Postgres 15 and later:
alter view public.notes_view set (security_invoker = true);
User metadata. auth.jwt() -> 'user_metadata' is editable by the signed-in user, so a policy that checks user_metadata ->> 'role' = 'admin' lets anyone promote themselves. Put roles in app_metadata (raw_app_meta_data), which users cannot write, or in a table with its own policies. Remember JWT claims only refresh when the token does.
Security definer functions. They skip RLS by design. Keep them out of exposed schemas, set search_path = '', schema-qualify every name, and revoke execute from anon where they are not meant to be public. The anon_security_definer_function_executable lint finds the ones you missed.
Step 5: Lock down storage buckets
Storage uses RLS on storage.objects, and by default nothing can be uploaded until a policy allows it. Make buckets private unless the contents are meant for the whole internet, and scope objects to a per-user folder:
insert into storage.buckets (id, name, public) values ('documents', 'documents', false);
create policy "documents_upload_own_folder" on storage.objects
for insert to authenticated
with check (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.jwt() ->> 'sub')
);
create policy "documents_read_own" on storage.objects
for select to authenticated
using (bucket_id = 'documents' and (select auth.jwt() ->> 'sub') = owner_id);
Upserts (overwriting a file) also need select and update policies. Public buckets serve files to anyone with the URL, and if one also has a broad select policy, clients can list every object in it; that is what public_bucket_allows_listing flags.
Step 6: Use publishable and secret keys correctly
Supabase's API keys guide maps them like this: the publishable key (sb_publishable_...) acts as anon or authenticated and belongs in client code; the secret key (sb_secret_...) acts as service_role, bypasses every policy, and belongs only on servers and in Edge Functions. Legacy anon and service_role JWTs are deprecated by the end of 2026.
Follow the migration guide: create the new keys under Settings, API Keys (the legacy ones keep working meanwhile), swap the publishable key into client code, swap the secret key into backends, then deactivate the legacy keys once nothing uses them. Deactivation is reversible. Then check that no secret made it into the build:
npm run build
grep -rEo 'sb_secret_[A-Za-z0-9_-]+|service_role' dist/ || echo "clean"
If a secret key ever leaks, create a new one, deploy it everywhere, then delete the old one. Supabase also automatically revokes secret keys found in public GitHub repos, which is a good reason to never hardcode one. To stop that happening at commit time, see the Gitleaks pre-commit guide.
Step 7: Keep Edge Function secrets server-side and check the caller
Anything privileged (payments, emails, third-party AI APIs) goes in an Edge Function. Set secrets with the CLI; names starting with SUPABASE_ are reserved:
supabase secrets set STRIPE_SECRET_KEY=sk_live_xxx OPENAI_API_KEY=sk-xxx
supabase secrets list
# Local development reads supabase/functions/.env (add it to .gitignore)
Functions get SUPABASE_URL, SUPABASE_PUBLISHABLE_KEYS and SUPABASE_SECRET_KEYS (JSON objects keyed by key name) automatically, per the secrets docs. The platform's verify_jwt check only understands legacy JWT keys, so with the new keys set verify_jwt = false and authorise in code:
# supabase/config.toml
[functions.create-checkout]
verify_jwt = false
// supabase/functions/create-checkout/index.ts
import { createClient } from "npm:@supabase/supabase-js@2.118.0";
const url = Deno.env.get("SUPABASE_URL")!;
const publishable = JSON.parse(Deno.env.get("SUPABASE_PUBLISHABLE_KEYS")!).default;
const secret = JSON.parse(Deno.env.get("SUPABASE_SECRET_KEYS")!).default;
Deno.serve(async (req) => {
// 1. Who is calling? An API key alone proves nothing.
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
if (!token) return new Response("unauthorized", { status: 401 });
const userClient = createClient(url, publishable);
const { data: { user }, error } = await userClient.auth.getUser(token);
if (error || !user) return new Response("unauthorized", { status: 401 });
// 2. Only now use the secret key, scoped to this user's rows.
const admin = createClient(url, secret);
const { data, error: dbErr } = await admin
.from("orders").select("id,total_cents").eq("user_id", user.id).eq("status", "pending");
if (dbErr) return new Response("error", { status: 500 });
return Response.json({ orders: data });
});
Step 8: Test as anon and as a second user
Now attack your own project. Replace the ref and key, and only run this against projects you own.
URL="https://YOUR_REF.supabase.co"; KEY="sb_publishable_xxx"
# Anonymous read: expect [] or a 42501 error, never real rows
curl -s "$URL/rest/v1/orders?select=*&limit=1" -H "apikey: $KEY"
# Anonymous write: expect 401/403, never 201
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$URL/rest/v1/orders" \
-H "apikey: $KEY" -H "Content-Type: application/json" \
-d '{"user_id":"00000000-0000-0000-0000-000000000000","total_cents":0,"status":"paid"}'
Then sign in as user A in the app, copy the access token from dev tools, and request user B's rows by ID with Authorization: Bearer <A's token>. You should get an empty array. For regression tests, supabase test db runs pgTAP files from supabase/tests, and supabase db advisors --linked --type security --fail-on warn exits non-zero in CI when a new lint appears.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Query returns [] but rows exist |
RLS on with no matching policy, or the policy is to authenticated and the request is anon |
Add a policy for the right role; confirm the client sends the user's session |
42501 permission denied for table |
No grant for the role (the 30 Oct 2026 default for new tables) | grant select ... on public.t to authenticated; in a migration |
new row violates row-level security policy |
with check failed, often user_id not set to auth.uid() |
Set user_id default to auth.uid() or send it; check the insert policy |
UPDATE affects 0 rows, no error |
Update policy exists but no select policy for the same rows | Add a matching for select policy |
| Anon can read everything through a view | View runs as owner | alter view ... set (security_invoker = true) |
| Users can make themselves admin | Policy reads user_metadata |
Move roles to app_metadata or a protected table |
Edge Function returns Invalid JWT |
New API key sent as Authorization: Bearer with verify_jwt on |
Send keys on apikey; set verify_jwt = false and authorise in code |
| Secret key returns 401 from the browser | Supabase blocks secret keys by User-Agent |
Move the call to an Edge Function; rotate the key if it shipped |
| Upload works, overwrite fails | Upsert needs select and update on storage.objects |
Add both policies scoped to the same folder |
| Slow queries after enabling RLS | Per-row auth.uid() calls, unindexed owner column |
Wrap as (select auth.uid()); index user_id |
FAQ
Is it safe to put the Supabase publishable key in my frontend?
Yes, that is what it is for. It acts as the anon role, or authenticated once a user signs in, so your RLS policies decide what it can do. The secret key is the one that must never reach a browser.
Does Supabase enable RLS by default?
For tables created in the dashboard, yes. Tables created by SQL, migrations or external tools may not have it, which is why the event trigger in Step 2 and the Security Advisor are worth having.
Why does my Supabase query return an empty array instead of an error?
Because RLS filters rows rather than rejecting the query. An empty result usually means no policy matched for the role making the request. A missing grant, by contrast, returns 42501 permission denied.
Do I still need RLS if I only call Supabase from my own server?
If the server uses the secret key, RLS does not apply to those calls, so your server code must do the authorisation. Keep RLS on anyway: the publishable key and the Data API still exist, and RLS is what protects the tables if someone reaches them directly.
Before any Supabase launch, the first thing I check is the anonymous curl against every table, because it answers the only question that matters in about a minute. Then I read the policies themselves, looking for true, user_metadata and missing with check, and I make sure the advisors run in CI so the next AI-generated migration cannot quietly undo it. Official references: Row Level Security, API keys, Storage access control, Edge Function secrets and the Data API grants changelog.
Related: How to Implement Multi-Tenancy with Postgres Row-Level Security (2026), How to Stop Secrets Leaking Into Git: Gitleaks, Pre-commit, CI (2026) and Secrets Sprawl 2026: 28.65M Leaked Keys and 64% Still Valid Years Later.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Implement SOC 2 Controls on AWS with Terraform (2026)
Step-by-step SOC 2 technical controls on AWS with Terraform: organization CloudTrail, AWS Config and a custom conformance pack, GuardDuty, Security Hub CSPM with CIS v5.0.0, Identity Center MFA, encryption defaults, AWS Backup vault lock, 400-day retention and evidence export.
18 minOct 31, 2026Vibe-Coded App Security in 2026: 170 Exposed Lovable Apps and 1.5M Keys
Why vibe-coded apps leak data in 2026: CVE-2025-48757 in Lovable apps, Moltbook's 1.5M exposed keys, the Base44 auth bypass, missing Supabase RLS, keys in JS bundles, what scanners catch and a pre-launch checklist.
13 minOct 31, 2026How to Build an AI Agent in n8n 2.45: Tools, Memory and MCP (2026)
Step-by-step n8n AI agent tutorial on n8n 2.45.7: Chat Trigger, AI Agent node, model credentials with a fallback, Postgres Chat Memory, HTTP, Code, sub-workflow and MCP Client tools, a structured output parser, error workflows, and a troubleshooting table for the real error messages.
16 min