← Back to blog
SecurityScanaris Team · 7 min read · October 03, 2026

Supabase security: what to review before you launch

Supabase gives you a database and an API in minutes, and that is exactly the danger: the API is exposed from the first moment. If you do not configure security, anyone with your public key can read and write your tables. This guide walks through everything to review before opening your app to the world, in the order that makes sense: RLS and policies with example SQL, which key goes where, Storage, views, the Auth settings, and how to verify at the end that it is really closed.

How Supabase exposes your data (and why that is a good thing)

In front of your Postgres sits a layer (PostgREST) that turns every table in the public schema into an HTTP endpoint. Your app in the browser talks to it directly with a public key. That saves you writing a backend, but it means no server of yours sits in between checking permissions: the Row Level Security policies are your backend. If they are right, the design is solid; if they are missing, the whole table is one URL away.

1. RLS enabled on EVERY table

Row Level Security is Supabase's main barrier. Without it, the public key can query whole tables straight from the browser. The classic vibe coder mistake is spinning up a new table fast —or letting the AI create it— and forgetting to enable RLS. It was exactly the cause of the Lovable vulnerability (CVE-2025-48757).

Check table by table in the Table Editor: the ones showing "RLS disabled" are open to the public. To not depend on eyeballing, this query in the SQL Editor lists the missing ones:

select c.relname
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r' and not c.relrowsecurity;

And for each one:

alter table public.table_name enable row level security;

Enabling it is not enough: a table with RLS on but no policies denies everything (your app stops working until you write them), and one with an overly permissive policy protects nothing.

2. Policies that truly reflect who can do what

A policy of using (true) for everyone leaves the table open even with RLS on; AI assistants suggest them often "so it works". Write specific policies and split the rules by operation, because sometimes you want to allow reads but not deletes.

Pattern A: each user owns their rows

create policy "notes: read own" on public.notes
  for select to authenticated
  using ((select auth.uid()) = user_id);

create policy "notes: insert own" on public.notes
  for insert to authenticated
  with check ((select auth.uid()) = user_id);

create policy "notes: update own" on public.notes
  for update to authenticated
  using ((select auth.uid()) = user_id)
  with check ((select auth.uid()) = user_id);

The with check is what stops anyone from inserting or editing a row on behalf of another user. Wrapping auth.uid() in (select …) makes Postgres evaluate it once per query rather than once per row; on large tables the difference is huge. Put an index on user_id.

Pattern B: team or organisation membership

create policy "projects: team members" on public.projects
  for select to authenticated
  using (
    exists (
      select 1 from public.team_members m
      where m.team_id = projects.team_id
        and m.user_id = (select auth.uid())
    )
  );

The team_members table needs its own policies too, and ideally only an admin can insert into it.

Pattern C: public read-only content

create policy "posts: public read" on public.posts
  for select to anon, authenticated
  using (published = true);

That is legitimate. What must not exist is an open write policy or columns with personal data in a publicly readable table.

3. anon, service_role and the newer keys

The anon key is public and goes in the frontend: it is meant to be governed by RLS. The service_role key bypasses RLS entirely and has full access. It must never, ever appear in the browser or in a NEXT_PUBLIC_, VITE_ or NUXT_PUBLIC_ variable: that prefix means "this is sent to the client".

Supabase has also introduced a newer pair of keys with names that make it obvious: sb_publishable_… (for the browser, subject to RLS) and sb_secret_… (server only, bypasses RLS, revocable separately). If you start a project today, use them; if you have an older one, migrate when you can. Use the secret key only on the server, in Edge Functions or trusted backends, and if you suspect it has leaked, rotate it immediately from the dashboard.

4. Storage: buckets have rules too

File storage uses its own policies, on the storage.objects table. A public bucket serves any file by URL to anyone who has it, forever. Decide which buckets are public on purpose (avatars, product images) and protect the rest. An example so that each user can only read and upload inside their own folder:

create policy "documents: own folder" on storage.objects
  for all to authenticated
  using (
    bucket_id = 'documents'
    and (storage.foldername(name))[1] = (select auth.uid())::text
  )
  with check (
    bucket_id = 'documents'
    and (storage.foldername(name))[1] = (select auth.uid())::text
  );

Upload private documents to private buckets and serve them with short-lived signed URLs, not to a public bucket with hard-to-guess names.

5. Views, functions and exposed schemas

Three places where data escapes even when the tables are fine:

  • Views. In Postgres a view runs with the permissions of whoever created it, so a view over a protected table can return rows the policies would forbid. Create them with (security_invoker = true) (Postgres 15 or later) or review each one.
  • RPC functions. A security definer function runs with its owner's permissions. Use it only when you truly need it, validate the parameters inside and limit who can call it with revoke execute … from anon.
  • Exposed schemas. Under Settings → API you choose which schemas the API publishes. Anything that should not go out over HTTP should not be on that list.

6. Auth settings worth reviewing

  • Email confirmation on, so nobody registers accounts with other people's addresses.
  • Rate limits on login attempts and email sending: they ship enabled; make sure nobody raised them "for testing".
  • Redirect URLs allowed only towards your domains; an open list lets attackers use your login to redirect to phishing sites.
  • OAuth providers: only the ones you use, with the consent screen under your brand name.
  • Magic link and OTP expiry kept short; the default is reasonable, do not extend it.

7. Secrets and keys out of the frontend

Beyond the Supabase secret key, check there are no third-party keys (Stripe, email, AI) baked into client JavaScript. AI sometimes drops them there to make something work fast, and they stay visible forever in the bundle. Calls to paid services go through an Edge Function or your server, never from the browser. See the secrets your app leaks in the browser.

8. Environments: development is not production

Use a separate Supabase project for development, with made-up data. A test project holding a copy of real data and half-finished policies is just as exposed as production, and nobody is watching it.

How to verify it is closed (the final test)

  • Incognito test. With the app open, copy a /rest/v1/ call from F12 → Network and open it with no session. It must return an empty list or an error.
  • Over-asking test. Logged in, remove the filter from the URL (?user_id=eq.…). Only your rows should come back.
  • Two-accounts test. Create two users and confirm neither sees, edits or deletes the other's data, Storage files included.
  • Security Advisor. In the dashboard, the Advisors section lists tables without RLS, permissive policies and exposed functions. Keep it green.

Scanaris reviews your Supabase for you

Scanaris includes an optional Supabase audit that, given a read-only connection string, checks table by table whether RLS is enabled and whether policies exist, and in the normal scan detects keys exposed in the frontend, permissive CORS and endpoints serving data with no session. Every finding comes with a prompt ready to paste into your AI. Run it before launch and you will know if you left a door open; turn on monitoring and it will tell you if the AI opens one again in a new table.

Frequently asked questions

Can I skip RLS by putting my own backend in front?

You can, by using only the secret key from your server and never the public one in the client. But then your backend has to perform every permission check RLS would. For most apps, RLS is less work and harder to break.

Does RLS hurt performance?

Barely, if the policies are well written: (select auth.uid()) instead of auth.uid(), indexes on the columns the policies use, and no heavy subqueries. A badly written policy can indeed slow down a large table.

What about tables the AI creates after launch?

They are born without RLS. Add "enable RLS and write the policies" to your routine (or to your AI's prompt) every time a new table appears, and use an automatic re-scan to find out if it gets forgotten.

How does your site do?

Run Scanaris and check it in 30 seconds, free.

Scan my site

Related checks

Security guides for your stack