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.
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.
| # | Check | Proved by |
|---|---|---|
| 1 | RLS is on for every table in public | SQL |
| 2 | You know which tables the anon role can touch | SQL |
| 3 | No policy says "true" where it means "mine" | SQL |
| 4 | No policy trusts user_metadata | SQL |
| 5 | The secret key is nowhere a user can reach | Repo + dashboard |
| 6 | You are on keys you can revoke | Dashboard |
| 7 | SECURITY DEFINER functions are pinned and fenced | SQL |
| 8 | Views respect the caller’s RLS | SQL |
| 9 | Every bucket has a policy you can explain | SQL |
| 10 | Sign-up and sign-in cannot be abused | Dashboard |
| 11 | "Signed in" means what your policies think it means | Dashboard + SQL |
| 12 | Channels are private and changes follow RLS | Dashboard + SQL |
| 13 | Every function with JWT checks off verifies something else | Repo + CLI |
| 14 | The database port and the team account are closed | Dashboard + CLI |
| 15 | You have restored a backup, not just taken one | Dashboard |
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:
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:
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:
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:
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:
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:
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:
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:
- 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. - Search the history, not just the current code:
git log --all --oneline -S "sb_secret_", and the same with the start of your key. - Check that no environment variable holding the key carries a prefix your framework exposes to the browser, such as
NEXT_PUBLIC_orVITE_.
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:
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:
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:
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:
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:
select id, public
from storage.buckets
order by public desc, id;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:
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:
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:
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:
select policyname, cmd, roles
from pg_policies
where schemaname = 'realtime'
and tablename = 'messages';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:
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:
grep -n -B2 "verify_jwt = false" supabase/config.tomllists the functions with checks off.- Read each one: what does it verify before it touches data, and does it use a secret key?
supabase secrets listshows 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.
supabase ssl-enforcement get --project-ref <ref> --experimental
supabase network-restrictions get --project-ref <ref> --experimentalThe 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
- Run the Security Advisor and save its output.
- Run checks 1 and 2. If any table is readable by
anonwithout RLS, stop and fix that first; nothing else on the list matters while a table is open. - Run checks 3, 4, 7, 8 and 9. Each failure is a named table, function, view or bucket.
- Search the build output and git history for the secret key (check 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.
Read Next
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.