writing

NC · article

Supabase Security Checklist: 15 Checks You Can Prove

Most Supabase checklists list topics. This one gives every item the SQL query or dashboard step that proves it passed, what leaks when it fails, and the fix, so you finish knowing whether you have a problem rather than suspecting one.

by Nic Chin16 min readSupabase / Security

part of Reviewing AI-Built Software · 5 articles

The short answer: a Supabase security checklist should cover row-level security on every public table, the grants behind it, policies that are not simply true, the secret key, SECURITY DEFINER functions and views, storage buckets, auth settings, Realtime channels, Edge Functions, the database perimeter and backups. Fifteen checks in all. Nine of them include a SQL query you paste into the SQL editor; the rest are a named dashboard screen, a CLI command or a search of your repository. Every one has a pass condition, so each comes back yes or no.

Why Every Check Here Comes With Its Proof

I have argued before that Supabase checklists do not help, because a list of topics gets read, nodded at and filed. “Enable RLS” is not a check. “Run this query; it should return no rows” is. So this list keeps the topics everyone agrees on and attaches the measurement to each, which is the only form of a checklist I find worth handing to a team.

It matters more for apps built with Lovable, Cursor, Bolt or Claude Code, because the risky part is not in the code anyone reads. My study of 53 public AI-built repositories could measure secrets and missing security tooling from the code, and said plainly what it could not see: in a Supabase app, row-level security lives in the database project, not the repository. Why that boundary goes missing is in why AI-built apps leak data. This page is how you check yours.

All the SQL is read-only and queries the Postgres catalog, not your data. Every Supabase behaviour described is linked to the Supabase or PostgreSQL documentation it came from, checked on 18 October 2026.

Start With the Security Advisor

Supabase ships a linter for exactly this, the Security Advisor. It is in the dashboard, and the CLI runs the same lints with supabase db advisors. Run it first. Its lints overlap with several checks below, and where they do I have named the lint, so you can match its output to the explanation.

Then keep going, for two reasons. A linter finds patterns; it cannot decide whether a policy matches who should see what in your business. Of the 20 launch-blocking items in the checklist that comes with my study, 16 need a person to check them. And several checks here, such as keys in a client bundle, Edge Function settings and a restored backup, are outside the database the Advisor inspects.

Data access: row-level security

1. RLS is on for every table in public

Check: Every table in the public schema has row-level security enabled.

What leaks if it fails: Supabase’s own words: “A table in an exposed schema without RLS is readable and writable by any role with a grant on it” (row level security docs). The role in question is reachable with the publishable (anon) key, which is meant to ship in your app. The Table Editor turns RLS on when you create a table in the dashboard; a table created with SQL does not get it unless you enable it (tables docs), and SQL is how migrations create tables.

How to prove it:

Check 1 - tables and their RLS flag
select schemaname, tablename, rowsecurity
from   pg_tables
where  schemaname = 'public'
order  by rowsecurity, tablename;

Passes when no row has rowsecurity = false. Failures sort to the top.

The fix:

Fix
alter table public.your_table enable row level security;

This fails safe: with RLS on and no policy, “no data is accessible through the API when using a publishable key, until you create policies”. Then write the policies. Tables with RLS on and no policy at all are measurement two in the production-readiness piece.

Security Advisor overlap: rls_disabled_in_public, policy_exists_rls_disabled

2. You know which tables the anon role can touch

Check: For every table, whether the anon and authenticated roles hold privileges on it, next to whether RLS is on.

What leaks if it fails: Policies and grants are two different gates. “A policy decides which rows a role reaches, not whether it holds privileges on the table” (tables docs). A row below with anon_read true and rls_on false is a table anyone can download. Supabase changed the default in 2026: new tables in public are no longer exposed to the Data API automatically. That became the default for new projects on 30 May 2026 and is applied to existing projects on 30 October 2026, but existing tables keep their current grants (Supabase changelog). An older project keeps the broad grants it already has.

How to prove it:

Check 2 - what each API role can reach
select c.relname                                     as table_name,
       c.relrowsecurity                              as rls_on,
       has_table_privilege('anon', c.oid, 'select')  as anon_read,
       has_table_privilege('anon', c.oid,
                           'insert, update, delete') as anon_write,
       has_table_privilege('authenticated', c.oid,
                           'select')                 as signed_in_read
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 c.relrowsecurity, c.relname;

Passes when every true in an anon column is a table you meant the public to reach, and no row combines anon_read with rls_on = false.

The fix:

Fix - take away what a role does not need
revoke insert, update, delete on public.your_table from anon;

3. No policy says "true" where it means "mine"

Check: Policies whose condition is literally true.

What leaks if it fails: using (true) shows every row to every role the policy applies to; with check (true) lets them write any row. Supabase says to use to anon using (true) only for public data (row level security docs). It is the usual way a table with RLS switched on is still, in effect, open: the check 1 query passes and nothing is protected.

How to prove it:

Check 3 - always-true policies
select schemaname, tablename, policyname, cmd, roles
from   pg_policies
where  schemaname in ('public', 'storage')
  and  (qual = 'true' or with_check = 'true')
order  by schemaname, tablename, policyname;

Passes when every row is a table whose entire contents you would publish on your homepage.

The fix:

Fix - tie the row to its owner
create policy "owner can read" on public.your_table
  for select to authenticated
  using ((select auth.uid()) = user_id);

Wrapping auth.uid() in a select lets Postgres evaluate it once per statement rather than once per row, and Supabase recommends an index on every column a policy filters on (same page).

Security Advisor overlap: permissive_rls_policy

4. No policy trusts user_metadata

Check: Policies that read user_metadata from the JWT, typically to decide who is an admin or which organisation someone belongs to.

What leaks if it fails: Supabase’s linter is blunt about it: user_metadata “is designed to be manipulated by the user themselves”, so it is not safe to rely on in RLS policies (database advisors). A policy that reads a role from it lets users choose their own role.

How to prove it:

Check 4 - policies that read user_metadata
select schemaname, tablename, policyname, cmd
from   pg_policies
where  coalesce(qual, '') || coalesce(with_check, '')
       ilike '%user_metadata%'
order  by schemaname, tablename;

Passes when it returns nothing.

The fix:

Move the fact the policy depends on, such as role or membership, into a table that only your server writes, and have the policy look it up there.

Security Advisor overlap: rls_references_user_metadata

Keys and secrets

5. The secret key is nowhere a user can reach

Check: The secret key (sb_secret_...) or the legacy service_role key is absent from the client bundle, any shipped mobile or desktop app, and the repository’s history.

What leaks if it fails: “A secret key bypasses every Row Level Security policy you have”, and Supabase says never to put one in a browser, a shipped application or source control (API keys docs). Supabase now rejects a secret key sent from a browser with a 401, but the same page warns that this does not stop someone using a leaked key from other tools. In my scan of 53 public AI-built repositories, at least 19% had committed a .env-family file (95% interval 11-31%) and at least 6% a high-confidence credential (2-15%). Those are floors: the scanner read a median of 16% of each repository’s files.

How to prove it:

  1. Build the app, then search the build output for sb_secret_ and for the first ten or so characters of your actual secret key value.
  2. Search the history, not just the current code: git log --all --oneline -S "sb_secret_", and the same with the start of your key.
  3. Check that no environment variable holding the key carries a prefix your framework exposes to the browser, such as NEXT_PUBLIC_ or VITE_.

The fix:

If it turns up anywhere, treat it as leaked. Supabase’s rotation order: create a new secret key in Settings > API Keys, replace it everywhere, confirm every component uses it, then delete the old one, last, because deleting a secret key cannot be undone. A legacy service_role key is deactivated rather than deleted.

6. You are on keys you can revoke

Check: The project uses publishable and secret keys, the legacy keys are deactivated, and you can name where each secret key is used.

What leaks if it fails: “Supabase is deprecating the anon and service_role keys by the end of 2026.” Creating the new keys does not revoke the legacy ones; those stay valid until you deactivate them (API keys docs). A leaked legacy key you never deactivated is still a working key. And every place a secret key is used is a path with no RLS on it, which is why the bypass surface is measurement three in the readiness piece.

How to prove it:

Open Settings > API Keys. Supabase says every key lives there, legacy or not. Are the legacy keys still active? Can you list, from memory, every service that holds a secret key?

The fix:

Move each component to the new keys, then deactivate the legacy pair. Deactivation is reversible, so you can re-enable them for a day if a client you missed starts failing.

Functions and views

7. SECURITY DEFINER functions are pinned and fenced

Check: Every security definer function in an exposed schema has a fixed search_path, and only the roles that need it can execute it.

What leaks if it fails: “A security definer function in an exposed schema is callable over the Data API with the creator’s privileges” (row level security docs). Table owners normally bypass row security (PostgreSQL row security), so a function created by the role that owns your tables usually skips RLS. Postgres grants EXECUTE to PUBLIC on new functions by default, and a definer function without a fixed search_path can be tricked into using objects an attacker created (PostgreSQL CREATE FUNCTION).

How to prove it:

Check 7 - definer functions in public
select p.proname                                         as function,
       coalesce(array_to_string(p.proconfig, ', '), '')  as settings,
       has_function_privilege('anon', p.oid, 'execute')  as anon_can_call
from   pg_proc p
join   pg_namespace n on n.oid = p.pronamespace
where  n.nspname = 'public'
  and  p.prosecdef
order  by p.proname;

Passes when every row shows search_path in settings, and anon_can_call is false unless signed-out visitors genuinely need that function. If you expose schemas besides public, add them to the filter.

The fix:

Fix
alter function public.your_function() set search_path = '';
revoke execute on function public.your_function() from anon, public;

With an empty search_path, schema-qualify every name inside the function, as Supabase recommends. Better still, keep definer functions out of exposed schemas entirely.

Security Advisor overlap: function_search_path_mutable, anon_security_definer_function_executable, authenticated_security_definer_function_executable

8. Views respect the caller’s RLS

Check: Every view in public is created with security_invoker = true.

What leaks if it fails: “Views bypass RLS by default because they are usually created with the postgres user” (row level security docs). In Postgres terms, the view owner’s policies are applied unless security_invoker is set, in which case the caller’s are (PostgreSQL CREATE VIEW). The option exists from Postgres 15. A convenient summary view over a protected table can quietly publish all of it.

How to prove it:

Check 8 - views that run as their owner
select c.relname as view_name, c.reloptions
from   pg_class c
join   pg_namespace n on n.oid = c.relnamespace
where  n.nspname = 'public'
  and  c.relkind = 'v'
  and  not exists (
         select 1
         from   unnest(coalesce(c.reloptions, '{}')) as o
         where  o ~* '^security_invoker=(true|on|yes|1)$'
       )
order  by c.relname;

Passes when it returns nothing.

The fix:

Fix
alter view public.your_view set (security_invoker = true);

Security Advisor overlap: security_definer_view

Storage

9. Every bucket has a policy you can explain

Check: Each bucket is public or private on purpose, and the policies on storage.objects tie private files to the people allowed to see them.

What leaks if it fails: Storage access is RLS on the storage.objects table (storage access control), a separate set of policies from your tables. Buckets are private by default; in a public bucket “anyone who possesses the asset URL can readily access the file”, while uploads and deletes are still checked (bucket fundamentals). A public bucket also needs no select policy to serve known paths, so one that has a broad select policy lets clients list its contents too.

How to prove it:

Check 9a - buckets
select id, public
from   storage.buckets
order  by public desc, id;
Check 9b - object policies
select policyname, cmd, roles, qual, with_check
from   pg_policies
where  schemaname = 'storage'
  and  tablename  = 'objects'
order  by policyname;

Passes when every public bucket holds only files you would hand to a stranger, and every private bucket’s policies name a user or a folder, not true.

The fix:

Fix - one folder per user, the pattern Supabase documents
create policy "upload to own folder" on storage.objects
  for insert to authenticated
  with check (
    bucket_id = 'documents'
    and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
  );

Repeat for select, update and delete. Uploads that overwrite with upsert also need select and update.

Security Advisor overlap: public_bucket_allows_listing

Auth settings

10. Sign-up and sign-in cannot be abused

Check: Email confirmation, OTP expiry, password rules, leaked password protection and auth rate limits.

What leaks if it fails: These are the settings Supabase’s own production checklist asks for. Leaked password protection checks new passwords against HaveIBeenPwned and is available on the Pro plan and above; Supabase does not recommend a minimum length under 8 characters (password security). Without a custom SMTP server, auth emails are limited to 2 per hour, which is a launch-day outage rather than a breach, but worth knowing before the launch.

How to prove it:

  • Authentication > Providers: email confirmations on; OTP expiry at 3600 seconds or lower, as the checklist recommends.
  • Auth settings for the email provider: leaked password protection on, minimum length 8 or more, required characters set.
  • Authentication > Rate Limits: read the numbers rather than assume them.
  • Authentication > Emails > SMTP Settings: a custom SMTP server on your own domain.

The fix:

Turn each one on. Supabase also offers CAPTCHA on the sign-up, sign-in and password reset endpoints if bots are already a problem.

11. "Signed in" means what your policies think it means

Check: Policies written to authenticated still hold up if anonymous sign-ins are on, and admin-only tables require a second factor.

What leaks if it fails: Anonymous users get the same authenticated Postgres role as permanent ones; their JWT carries an is_anonymous claim you can check (anonymous sign-ins). So enabling anonymous sign-ins quietly widens every to authenticated policy to anyone who opens the app. For MFA, the JWT’s aal claim is aal2 only after a second factor, and a restrictive policy can require it (MFA docs).

How to prove it:

In the Authentication settings, is anonymous sign-in enabled? If it is, list the write policies that do not mention is_anonymous:

Check 11 - write policies open to anonymous users
select tablename, policyname, cmd
from   pg_policies
where  schemaname = 'public'
  and  'authenticated' = any (roles)
  and  cmd in ('INSERT', 'UPDATE', 'DELETE', 'ALL')
  and  coalesce(qual, '') || coalesce(with_check, '')
       not ilike '%is_anonymous%'
order  by tablename, policyname;

The fix:

Fix - restrictive policies combine with AND, so they cannot be outvoted
create policy "permanent users only" on public.your_table
  as restrictive for insert to authenticated
  with check ((select (auth.jwt()->>'is_anonymous')::boolean) is false);

create policy "admins need mfa" on public.admin_settings
  as restrictive to authenticated
  using ((select auth.jwt()->>'aal') = 'aal2');

Security Advisor overlap: auth_allow_anonymous_sign_ins

Realtime

12. Channels are private and changes follow RLS

Check: Broadcast and Presence channels are private, public access is switched off, and every table that streams changes has RLS on.

What leaks if it fails: Realtime Authorization is RLS on the realtime.messages table. Clients opt in with private: true, and to enforce it you disable the “Allow public access” setting in Realtime settings (Realtime authorization). Postgres Changes only deliver records the client is allowed to read under your policies, so a table that fails check 1 streams its changes to anyone who can subscribe. Access is cached for the connection, so a revoked user keeps receiving until their JWT is refreshed or expires.

How to prove it:

In Realtime settings, confirm “Allow public access” is off. Then:

Check 12a - channel policies
select policyname, cmd, roles
from   pg_policies
where  schemaname = 'realtime'
  and  tablename  = 'messages';
Check 12b - tables that publish changes
select pubname, schemaname, tablename
from   pg_publication_tables
order  by pubname, schemaname, tablename;

Passes when 12a has a policy for each kind of channel you use, and every table in 12b passed check 1.

The fix:

Fix - members receive broadcasts for their own rooms
create policy "members receive broadcasts" on realtime.messages
  for select to authenticated
  using (
    exists (
      select 1 from public.room_members m
      where  m.user_id    = (select auth.uid())
        and  m.room_topic = (select realtime.topic())
    )
    and realtime.messages.extension in ('broadcast')
  );

Edge Functions

13. Every function with JWT checks off verifies something else

Check: Each Edge Function with verify_jwt = false checks a webhook signature or authenticates the caller itself before doing anything.

What leaks if it fails: Edge Functions require a valid JWT by default. Turning that off “will allow anyone to invoke your Edge Function without a valid JWT” (function configuration). There are good reasons to: a Stripe webhook cannot send your users’ tokens. But the secret keys available inside functions bypass RLS (function secrets), so a public function holding one is a public door with no policy behind it.

How to prove it:

  1. grep -n -B2 "verify_jwt = false" supabase/config.toml lists the functions with checks off.
  2. Read each one: what does it verify before it touches data, and does it use a secret key?
  3. supabase secrets list shows which secrets the deployed project holds; none should also be in the repository.

The fix:

Verify the provider’s signature first thing in every webhook function. For anything acting for a user, leave JWT verification on and query with a client scoped to the caller’s token, so RLS applies. Keep .env in .gitignore.

Operations: network, team and backups

14. The database port and the team account are closed

Check: SSL is enforced on database connections, network restrictions allow only known addresses, and the Supabase organisation has MFA and more than one owner.

What leaks if it fails: Both settings cover direct Postgres and pooler connections, the route a leaked database password would use. They do not cover the HTTPS APIs: Supabase’s HTTP APIs always enforce SSL, and network restrictions do not apply to PostgREST, Storage or Auth (SSL enforcement, network restrictions). The production checklist also asks for MFA on your Supabase account, suggests considering MFA enforcement for the organisation, and more than one owner.

How to prove it:

  • Database > Settings > SSL Configuration: “Enforce SSL on incoming connections” on.
  • Database > Settings > Network Restrictions: a list of CIDRs, not open to all.
  • Organization > Team: at least two owners, each with MFA.
Or from the CLI
supabase ssl-enforcement get --project-ref <ref> --experimental
supabase network-restrictions get --project-ref <ref> --experimental

The fix:

Enable both. One side effect to plan for: with network restrictions on, Edge Functions lose direct database access and should connect through supabase-js instead.

15. You have restored a backup, not just taken one

Check: A backup exists for the data loss you can tolerate, and someone has restored it once.

What leaks if it fails: Pro projects can access 7 days of daily backups, Team 14 and Enterprise up to 30; Supabase advises Free projects to export their own with supabase db dump. Point-in-Time Recovery is an add-on for a lower recovery point. And database backups do not include files stored through the Storage API (backups docs), so a restore brings back the rows that point at your files, not the files.

How to prove it:

Open Database > Backups and check what is there. Then restore one into a new project, which the docs cover under duplicating a project, and time it.

The fix:

Enable PITR if a day of lost data is unacceptable (it needs at least a Small compute add-on, and replaces daily backups). Back up storage files separately.

What Passing Looks Like

On a multi-portal property and utilities platform I led as fractional CTO, row-level security ran across a 31-table PostgreSQL schema serving separate admin, agency and tenant portals. Checks 1 to 4 are not hard on a schema like that, because tenancy was decided once and every policy restated the same rule. They are hard on a schema where each table got a policy when a feature needed one, which is the shape the four readiness measurements are designed to detect.

What to Do on Monday

  1. Run the Security Advisor and save its output.
  2. Run checks 1 and 2. If any table is readable by anon without RLS, stop and fix that first; nothing else on the list matters while a table is open.
  3. Run checks 3, 4, 7, 8 and 9. Each failure is a named table, function, view or bucket.
  4. Search the build output and git history for the secret key (check 5).
  5. Work through the dashboard checks in one sitting: 6, 10, 11, 12, 14 and 15.

Most of that is an afternoon. The part that takes judgement is deciding what each policy should say, which no query can tell you.

Frequently Asked Questions

What should a Supabase security checklist include?

Row-level security on every table in the public schema, the grants that decide which tables the anon and authenticated roles can touch, policies that are not simply "true", the secret or service-role key kept out of the client and the repository, SECURITY DEFINER functions and views that cannot bypass RLS, storage bucket policies, auth settings such as email confirmation and leaked password protection, Realtime channel authorisation, Edge Functions that verify their callers, SSL enforcement and network restrictions, and a backup you have actually restored. Each item should come with a query or dashboard step that proves it, or it is a topic rather than a check.

What does the Supabase Security Advisor check?

It runs database lints against your project and reports issues such as rls_disabled_in_public, policy_exists_rls_disabled, security_definer_view, function_search_path_mutable, rls_references_user_metadata, permissive_rls_policy and public_bucket_allows_listing. It is the right place to start and it is not the whole job: a lint cannot decide whether a policy matches who should see what in your business, and keys in a client bundle, Edge Function settings and restored backups are outside the database it inspects.

Is the Supabase anon or publishable key safe to expose?

Yes, by design. Supabase describes publishable keys as safe to expose in web pages, apps and source code, and the legacy anon key as the older version of the same thing. What protects your data is row-level security and grants, not secrecy of that key. The secret key, and the legacy service_role key, are the opposite: a secret key bypasses every RLS policy, and Supabase says never to put one in a browser, a shipped application or source control.

Do Supabase tables have RLS enabled by default?

Only if you create them in the dashboard. Supabase’s docs say the Table Editor enables row level security when you create a table there, and that when you create a table with SQL you must enable it yourself. Migrations and SQL scripts are therefore where unprotected tables usually come from, which is why check 1 is a query rather than a setting.

How often should these checks be run?

The SQL checks after every migration that adds a table, function, view or policy, because that is when they change. Supabase’s CLI can run the Security Advisor as supabase db advisors, so it can sit in a deployment pipeline. The dashboard checks, such as auth settings, network restrictions and backups, change rarely: once before launch and again whenever someone new gets owner access is a reasonable rhythm.

If You Want Someone to Run These for You

If you want someone to run these for you and tell you what the results mean for your data model, that is a Supabase architecture review, which goes through policies, storage, functions and the paths around them in full. If the app was built with Lovable, Cursor or a similar tool and you want a bounded first look before launch or fundraising, there is a fixed-scope app review from $1,300 (£1,000), for teams in the UK, US and Europe.

Ready to discuss your AI project?

Book a 45-minute call, at no charge, to explore how AI can transform your business. Or if you already have a codebase, get an instant architecture report at SystemAudit.dev No technical knowledge needed, results in 3 minutes.

Or describe your project in the chat, or email nic.chin@nicchin.com

About the Author

Nic Chin is an independent AI consultant and fractional CTO who helps companies design and deploy production AI systems including RAG pipelines, multi-agent systems, and AI automation platforms. He has delivered enterprise AI solutions across the UK, US, and Europe, and provides AI consulting in Malaysia.