SUPABASE SECURITY CHECKLIST

Supabase exposes Postgres directly to the browser via PostgREST, which is the source of both its speed and its most common security failures. Without Row Level Security, the anon and authenticated roles can SELECT * FROM users from the network tab. With the wrong policies — or with service_role accidentally in the client bundle — the same is true. The checklist below is what we look for first when we audit a Supabase project.

Treat Critical as launch-blocking. High is week-one. Medium is the cleanup once you have users.

How to use this checklist

Walk it once in the Supabase dashboard for your production project, ticking items as you go. Run the SQL audit queries below in the SQL editor; they’ll catch most of the items mechanically. After the whole list passes, run a black-box scan against the deployed app to confirm the policies actually behave the way the dashboard says.

Critical (fix before launch)

1. Enable Row Level Security on every public table

Why it matters. PostgREST exposes every table in the public schema directly to the browser. Without RLS, the anon and authenticated roles can read, write, update, and delete any row. This is the single most common breach vector for Supabase apps.

How to check. In the SQL editor, run:

select schemaname, tablename
from pg_tables
where schemaname = 'public'
  and rowsecurity = false;

Any rows returned are tables exposed without RLS.

How to fix. Enable RLS on each (alter table public.<name> enable row level security), then add policies. RLS without a policy denies everything — that is correct, not broken. Add using (auth.uid() = user_id) for owner-scoped data, or using (true) for read-public tables (and only those).

2. Add at least one policy per table — RLS without policies still needs intentional grants

Why it matters. RLS enabled with no policy denies all access. That’s the safe default but it breaks the app, which sometimes leads developers to “fix” it by re-disabling RLS or by writing using (true). Both undo the protection.

How to check. Run:

select tablename
from pg_tables t
where schemaname = 'public'
  and rowsecurity = true
  and not exists (
    select 1 from pg_policies p
    where p.schemaname = 'public' and p.tablename = t.tablename
  );

Tables returned have RLS but no policies — they’ll deny everything until you add one.

How to fix. Write the appropriate policy. For owner-scoped: using (auth.uid() = user_id). For role-gated: using (auth.jwt() ->> 'role' = 'admin'). Test each before considering the table safe.

3. Rotate service_role if it ever touched the client

Why it matters. service_role bypasses every RLS policy. The key is meant to be used only on the server side. AI coding tools (Lovable, Bolt, v0, Cursor) sometimes paste service_role into client-side code “to bypass the RLS issue”, or into a chat transcript that gets retained.

How to check. Search the repo, browser bundle, and chat history for the key prefix. In the deployed app, open DevTools → Network and inspect any Supabase request — if you see Authorization: Bearer eyJ... and the JWT decodes (jwt.io) to "role": "service_role", the key is in the client.

How to fix. Rotate the key in Settings → API, update server-only env vars, and redeploy. Never reference SUPABASE_SERVICE_ROLE_KEY from any file under src/, app/, pages/, or any client-bundled directory.

4. Lock every storage bucket behind a policy

Why it matters. Storage buckets and RLS for storage are two separate switches. A bucket marked “private” without policies still allows operations because the default has no policies enforcing the privacy. Conversely, a bucket marked “public” exposes every file regardless of any policy.

How to check. In Storage → Policies, every bucket should show explicit select, insert, update, and delete policies. For private buckets, confirm the bucket toggle is off and there’s a policy. For public buckets, confirm filenames are unguessable (UUID, not user-id-1.png).

How to fix. Add per-operation policies that scope by auth.uid(). For avatars: ((storage.foldername(name))[1] = auth.uid()::text). Stop relying on filename obscurity.

5. Verify auth.users is not exposed via a view that bypasses RLS

Why it matters. A common pattern is to create a public.users view that joins auth.users to a profile table — and that view, by default, runs with the view owner’s permissions. If the owner is a superuser, the view bypasses RLS, letting any signed-in user read every email in auth.users.

How to check. Run:

select schemaname, viewname, viewowner
from pg_views
where schemaname = 'public'
  and viewname like '%user%';

For any view returning, check whether it joins auth.users and what its security setting is.

How to fix. Recreate views with security_invoker = on (Postgres 15+) so they run with the caller’s permissions. For older versions, drop the view and create a function with security definer that explicitly filters.

6. Restrict the anon role to only the tables it needs

Why it matters. anon is the role used for unauthenticated PostgREST requests. By default it has select access on every table in public. Combined with missing or permissive RLS, that means every unauthenticated visitor can read every row.

How to check. Run:

select table_name, privilege_type
from information_schema.role_table_grants
where grantee = 'anon' and table_schema = 'public';

If anon has any privilege on a table that should require login, that’s a problem.

How to fix. revoke select, insert, update, delete on table public.<name> from anon. Belt and braces alongside RLS.

High (fix in the first week)

7. Configure SMTP auth rate limits to block enumeration

In Authentication → Settings → Rate Limits, configure per-IP and per-email rate limits on signup, password reset, OTP, and magic link. Without these, attackers fund someone else’s email enumeration and SMS bill.

8. Add CAPTCHA on signup and password reset

In Authentication → Settings → Enable CAPTCHA, configure hCaptcha or Turnstile. Apply to signup, password reset, and OTP flows. Required for any app with credit-card-shaped abuse vectors.

9. Set short JWT expiry on sensitive apps

Default JWT expiry is one hour. For apps touching money, health, or PII, drop to 15-30 minutes via Settings → API → JWT Expiry. Refresh tokens stay long but JWTs short.

10. Configure auth provider OAuth redirect URLs

In Authentication → URL Configuration, the “Site URL” and “Redirect URLs” must list only your real production domains. Wildcards or localhost left from development let attackers complete OAuth on a domain they control.

11. Enable email enumeration protection

In Authentication → Settings, enable “Confirm email” and “Secure email change”. Configure error messages to be identical for “user exists” and “user does not exist” on signup, password reset, and magic link.

12. Verify webhook handlers check signatures

Supabase webhooks (and Stripe, Resend, etc. webhooks called from your Edge Functions) all sign payloads. Confirm every webhook handler verifies the signature before trusting the body.

Medium (fix when you can)

13. Enable Postgres SSL enforcement

In Database → Settings, require SSL for all connections. Reject plaintext connections at the Postgres level, not just at the proxy.

14. Disable Supabase Studio in production

If you self-host or expose Studio under a custom domain, lock it behind SSO or take it offline entirely. Studio access is equivalent to root on the database.

15. Set up audit logging on auth events

In Logs → Auth Logs confirm logging is on, then ship those logs to whatever you use for monitoring. You want a record of password resets, role changes, and failed logins long after Supabase’s default retention.

16. Restrict direct database connections to known IPs

In Database → Network Restrictions enable IP allowlisting if your backend services connect directly to Postgres. Disable the pooler for environments that don’t need it.

17. Enforce password length and breach-list checks

In Authentication → Settings → Password Strength, set minimum length to 12 and enable HaveIBeenPwned check. The default 6-character minimum is from a different decade.

18. Pin function and trigger versions

Database triggers and Edge Functions evolve. Tag a migration version on every change so you can roll back a misbehaving function without losing the data.

After every schema change

  • Re-run the RLS audit query from item 1.
  • Re-run the policy audit query from item 2.
  • Diff public views; confirm no new view leaks auth.users.
  • Re-test cross-user access on the changed tables (sign in as B, try to read A’s row).

Common attack patterns we see in Supabase apps

The disabled-RLS table. Developer added a new table via the dashboard, didn’t tick “enable RLS”. Anonymous PostgREST request reads every row.

The leaked service_role. AI coding tool pasted service_role into a frontend env var. Bundle ships, attacker decodes the JWT, full admin database access.

The public storage bucket of avatars. Bucket marked public for “easy access”. Filenames are sequential. Attacker enumerates user-1.jpg through user-50000.jpg, dumps every avatar.

The view that exposed auth.users. public.user_directory view joins auth.users.email for “easy lookups”. View owner is superuser; RLS bypassed; entire user list readable to any signed-in user.

How to Secure Supabase

Step-by-step guide for hardening a Supabase project — RLS patterns, storage policies, auth configuration, and the migration patterns above in long form.

Is Supabase Safe?

In-depth analysis of Supabase’s defaults — what’s secure out of the box, what isn’t, and what we find when we audit a typical Supabase app.

Automate Your Checklist

A checklist tells you what to look for. A scanner tells you what’s actually broken in the deployed app right now. VibeEval drives a real browser through your Supabase-backed app, attempts the cross-user RLS bypasses and storage enumeration above, and reports what got through — with the exact policy or bucket to fix.

SCAN YOUR SUPABASE APP

14-day trial. No card. Results in under 60 seconds.

START FREE SCAN