How to Secure a Supabase App Before Launch: RLS Checklist (2026)

Oct 31, 2026 · 13 min · Ajay Kumar

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.

What happens to a request with the publishable key 1. Browser apikey: sb_publishable + optional user JWT 2. Role chosen no JWT: anon valid JWT: authenticated 3. Grant check GRANT on the table? no: 42501 denied 4. RLS policies using: rows visible with check: writes 5. Result no matching policy: [] (empty, no error) failed with check: new row violates RLS policy Secret key: sb_secret maps to service_role BYPASSRLS skips steps 3 to 5 entirely servers and Edge Functions only, never the browser Since 30 Oct 2026: new public tables get no grants by default Ship GRANT, ENABLE ROW LEVEL SECURITY and CREATE POLICY in the same migration, then test as anon.
Request flow for the Supabase Data API, based on the API keys guide, the RLS docs and the 30 October 2026 grants changelog linked above.

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