HomeGuidesSupabase Security Advisor warnings explained and fixed

Guide

Supabase Security Advisor warnings explained and fixed

What each Supabase Security Advisor lint means, which ones are urgent, and the SQL fix: RLS disabled, mutable search_path, definer views, and more.

21 min read

Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.

You open the Supabase dashboard and see a red badge: "RLS Disabled in Public," "Function Search Path Mutable," "Security Definer View," "Leaked Password Protection Disabled." If an AI tool such as Lovable, Bolt, Cursor, or v0 wrote your schema, a list like that is normal. Some of those findings mean anyone with your project URL can read or delete data today. Others are hardening you can do next week.

This guide goes through every Security Advisor finding a typical vibe-coded app gets, says in plain words what the real risk is, gives the SQL that clears it, and tells you which ones can wait. For the table policies themselves, keep the Supabase RLS checklist open next to this page.

Checked on October 19, 2026 against the Supabase advisor docs, the lint SQL in the open source splinter repo, the Supabase Studio source, Supabase CLI 2.121.0, and the PostgreSQL docs linked below. We also ran splinter against a Postgres 17 test database set up with Supabase's roles and grants, applied each fix, and confirmed the findings cleared. VibeLocked is independent and not affiliated with Supabase.

The short version

  1. ERROR means fix it today. These findings usually mean data is readable or writable over the API with the key that ships in your frontend.
  2. Some WARN findings are just as serious. A SECURITY DEFINER function that anon can call, or a policy with using (true), can expose every row.
  3. Enabling RLS without a policy blocks your app. Write the policies in the same migration.
  4. Mutable search_path, extension in public, MFA options, and the Postgres version warning are hardening. Schedule them, do not ignore them.
  5. Re-run the advisor after every migration. AI tools add new tables and functions all the time, and each one can bring a finding back.

Where to find the Security Advisor

In the dashboard, open your project and go to Advisors, then Security Advisor. Supabase Studio sorts findings into three tabs, and its own descriptions are a good guide to urgency:

  • Errors: "You should consider these issues urgent and fix them as soon as you can."
  • Warnings: "You should try and read through these issues and fix them if necessary."
  • Info: "You should read through these suggestions and consider implementing them."

Supabase's advisors page lists other ways to run the same checks:

  • CLI: supabase db advisors. In CLI 2.121.0 it takes --linked or --local, --type security, --level, and --fail-on, so supabase db advisors --linked --type security --fail-on error exits non-zero when any ERROR is present.
  • MCP: the Supabase MCP server's get_advisors tool with type set to security. If Cursor or Claude Code talks to Supabase over MCP, ask it to run this after each migration.
  • Management API: the security advisors endpoint.

Do not confuse it with supabase db lint. The CLI reference says that command runs plpgsql_check to find errors in your functions. It will not report a table with RLS disabled.

Almost every numbered lint (0012 is the exception) is plain SQL in the splinter repo, and splinter.sql holds all of them in one query you can run in your own project's SQL editor. Findings without a number, such as auth_leaked_password_protection, are platform checks on your Auth and project settings. They are not in splinter.

Prioritized table of Security Advisor lints

Levels come from the lint SQL in splinter, which is what the Advisor reports. Platform checks (no number) are not in splinter; their level is what the Advisor returned for our test project or in public Advisor output we reviewed.

Lint Level Priority What it means
0013_rls_disabled_in_public ERROR Today A public table has RLS off, so anyone with the URL and publishable key can read and write it
0023_sensitive_columns_exposed ERROR Today Same as 0013, and the table has columns named like password, token, or ssn
0002_auth_users_exposed ERROR Today A view exposes auth.users data to API roles
0010_security_definer_view ERROR Today A view in public runs as its owner and ignores RLS
0015_rls_references_user_metadata ERROR Today A policy trusts user_metadata, which users can edit
0007_policy_exists_rls_disabled ERROR Today You wrote policies, but RLS is off, so they do nothing
0019_insecure_queue_exposed_in_api ERROR Today A Supabase Queue is reachable over the API without RLS
0021_fkey_to_auth_unique ERROR This week A foreign key blocks future Auth migrations
0028_anon_security_definer_function_executable WARN This week Signed-out visitors can call a function that bypasses RLS
0029_authenticated_security_definer_function_executable WARN This week Any signed-in user can call a function that bypasses RLS
0024_rls_policy_always_true WARN This week A policy uses using (true) or with check (true)
0016_materialized_view_in_api WARN This week API roles can read a materialized view, which cannot use RLS
0017_foreign_table_in_api WARN This week API roles can read a foreign table, which cannot use RLS
0025_public_bucket_allows_listing WARN This week Anyone can list every file in a public Storage bucket
auth_otp_long_expiry WARN This week Email OTPs and links stay valid for more than an hour
auth_leaked_password_protection WARN This week (Pro plan) New passwords are not checked against known leaked passwords
0011_function_search_path_mutable WARN Schedule A function has no fixed search_path
0014_extension_in_public WARN Schedule An extension's objects sit in the API-exposed public schema
0022_extension_versions_outdated WARN Schedule An extension is behind its default version
0026 and 0027_pg_graphql_*_table_exposed WARN Schedule Table and column names are visible through GraphQL introspection
auth_insufficient_mfa_options WARN Schedule Fewer MFA methods are enabled than Supabase recommends
vulnerable_postgres_version WARN Schedule (plan downtime) Your Postgres build has security patches available
0008_rls_enabled_no_policy INFO Check it RLS is on but no policy exists, so the API sees nothing
0012_auth_allow_anonymous_sign_ins INFO Check it Anonymous users share the authenticated role

Two naming quirks you may hit. Supabase's advisor docs list 0007 as INFO, but the lint SQL in splinter has reported it as ERROR since April 2024, and it showed as ERROR in our test run. And lint 0024 reports itself as rls_policy_always_true, while its docs anchor is 0024_permissive_rls_policy.

Fix today: the ERROR findings

RLS Disabled in Public (0013) and Sensitive Columns Exposed (0023)

This is the one that matters most. Tables in public are served by the Data API. Supabase's lint docs say that without RLS, "anyone with the project's URL can CREATE/READ/UPDATE/DELETE (CRUD) rows in the impacted table," and call it "a critically unsafe configuration." The project URL and the publishable (or legacy anon) key are in your frontend bundle by design, so "anyone" really means anyone. Lint 0023 is the same finding on a table whose column names look like passwords, tokens, payment data, or government IDs.

The one-line fix is alter table public.notes enable row level security;. Do not run it alone on a live app. Supabase's RLS guide says: "Once RLS is enabled, no data is accessible through the API when using a publishable key, until you create policies." If your frontend reads that table, it suddenly gets empty arrays, and writes fail. Safe, but broken.

The current Supabase guide adds a second step: set the table grants to match what each role actually does, because adding policies does not take away the default grants. Do all of it in one migration:

-- 1. Turn on RLS
alter table public.notes enable row level security;

-- 2. Give each role only the operations it needs
revoke all on table public.notes from anon, authenticated;
grant select, insert, update, delete on table public.notes to authenticated;

-- 3. One policy per operation, scoped to the owner
create policy "Users read own notes"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id );

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

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

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

Notes on writing a correct policy:

  • to authenticated matters. Without a to clause the policy applies to every role, including anon.
  • Write both clauses on updates. using decides which rows a user can touch, and with check decides what the row may look like afterwards. The PostgreSQL CREATE POLICY docs say that when with check is missing on an update policy, the using expression is reused for it. Writing both keeps the rule explicit when someone later loosens using, for example to a team check, and stops a user from moving a row to another user_id.
  • Wrap auth.uid() in select. Supabase's performance lint 0003_auth_rls_initplan exists because a bare auth.uid() runs once per row. (select auth.uid()) runs once per query.
  • Public data gets an explicit public policy. A blog post table can have to anon, authenticated using (true) for select, with no write grants for anon. Make that choice on purpose, table by table.

If you do not use the Data API at all (for example, every query runs from your own server with a direct database connection), the lint docs offer another fix: remove public from the exposed schemas in the API settings.

For 0023, also ask whether the column belongs there at all. Password hashes already live in auth.users, and API tokens belong on the server.

Before you call it fixed, test as the browser would. The SQL editor runs as postgres, which bypasses RLS, so simulate the API roles inside a transaction:

begin;
set local role anon;
select count(*) from public.notes;   -- expect 0
rollback;

begin;
set local role authenticated;
set local request.jwt.claim.sub = '<user-a-uuid>';
select distinct user_id from public.notes;   -- expect only user A
rollback;

We ran exactly this on our test database: after enabling RLS with no policy, anon saw 0 rows; after adding the select policy, user A saw only their own row. To keep it that way, turn these checks into a test that runs on every pull request (testing Supabase RLS in CI).

Policy Exists RLS Disabled (0007)

You (or your AI tool) wrote policies but never turned RLS on, so Postgres ignores them and the table is as open as in 0013. The fix is the same alter table ... enable row level security;. Read the existing policies first, because enabling RLS makes them live. If one of them says using (true), you have swapped one open door for another.

Security Definer View (0010) and Exposed Auth Users (0002)

A Postgres view checks the underlying tables with the view owner's rights by default. On Supabase the owner is usually postgres, which bypasses RLS, so a view in public can return every row your table policies were meant to hide. On Postgres 15 and above the fix is one line:

alter view public.order_summaries set (security_invoker = on);

A view over auth.users (0002) should usually become a public.profiles table filled by a trigger. Grants, the create or replace view trap, and tests for both are in Supabase views and functions that bypass RLS.

RLS references user metadata (0015)

A policy that reads auth.jwt() -> 'user_metadata' trusts data the user controls. Supabase's lint docs show the attack in one line: a signed-in user calls updateUser({ data: { is_admin: true } }) and the policy believes them. Move roles to app_metadata (only your server can set it), a custom access token hook, or a separate user_roles table that users cannot write. The client-side auth guide shows both setups.

Queue exposed (0019) and foreign key to auth unique (0021)

For 0019, enable RLS on the queue table (alter table pgmq.q_<queue_name> enable row level security;) and add policies, or remove pgmq_public from the exposed schemas. Lint 0021 is not a leak: a foreign key points at an auth unique constraint Supabase plans to remove, which would block Auth security updates. Point it at auth.users(id) instead.

Fix this week: WARN findings that are real exposure

SECURITY DEFINER functions anon or signed-in users can call (0028, 0029)

A SECURITY DEFINER function runs as its owner, usually postgres, so it skips RLS. Postgres grants EXECUTE on new functions to PUBLIC, the lint docs say Supabase also grants them to anon, authenticated, and service_role, and PostgREST serves them at /rest/v1/rpc/<name>. So an admin helper becomes an endpoint anyone can call. This is often how an RLS recursion fix goes wrong: the helper lands in public. Fixing the infinite recursion error puts it in a private schema instead. For 0029, the docs add that with open signup "the audience for authenticated is effectively the public internet."

The fix is per function. Most of the time, revoke:

revoke execute on function public.get_all_orders() from public, anon, authenticated;

If the function only needs normal user access, switch it to SECURITY INVOKER so RLS applies:

alter function public.get_all_orders() security invoker;

Two things we checked on Postgres 17 that save you worry:

  • Trigger functions are a false alarm, but revoke anyway. The classic Supabase handle_new_user() trigger shows up in 0028 and 0029. Postgres refuses to run a trigger function outside a trigger ("trigger functions can only be called as triggers"), and in our test, revoking EXECUTE from every API role did not stop the trigger from firing on insert. Revoking clears the warning and costs nothing.
  • Some functions are meant to be public. A "confirm my newsletter token" or "submit contact form" function may need to be callable by anon. Then it is a public API endpoint: validate every argument, limit what it touches, never build SQL from its input, and accept the warning on purpose.

Supabase's securing your API guide shows how to change default privileges so the next function is not public by default.

RLS policy always true (0024)

A policy with using (true) or with check (true) for anon or authenticated means RLS is on but every row is allowed. The lint docs note it often comes from placeholder policies "during development" that nobody updated, which is exactly how AI-generated migrations look. Replace it with an owner check like the ones above. If the table really is public (a product catalog, published posts), keep using (true) on select only, never on insert, update, or delete, and accept the warning for that table.

Materialized views and foreign tables in the API (0016, 0017)

Neither can use RLS. If API roles have select, everyone sees every row. Revoke, including from public, which carries default permissions:

revoke select on public.order_totals from public, anon, authenticated;

Read them from server code instead, or through a function that applies the owner check.

Public bucket allows listing (0025)

A public bucket already serves files by URL. Adding a broad select policy on storage.objects also lets clients list every file name in it. Drop that policy unless listing is the point. The Supabase Storage bucket security guide covers private buckets and owner policies.

Auth OTP long expiry (auth_otp_long_expiry)

The advisor text says you "enabled the email provider with the OTP expiry set to more than an hour." Supabase's production checklist recommends "3600 seconds (1 hour) or lower." The setting is Email OTP expiration under Authentication, Sign In / Providers, Email, and it covers email OTP codes and links. A long-lived code gives an attacker more time to use a code from an inbox they briefly got into. Set it to 3600 or less. Studio also has a related auth_otp_short_length check for the Email OTP length field, which accepts 6 to 10 digits.

Leaked password protection disabled (auth_leaked_password_protection)

This setting rejects passwords found in the HaveIBeenPwned Pwned Passwords data on signup and password change. It is a cheap defense against credential stuffing, where attackers try passwords leaked from other sites. In Studio it is the Prevent use of leaked passwords toggle in the Email provider settings.

The plan matters. Supabase's password security docs say: "Leaked password protection is available on the Pro Plan and above." The warning still appears on Free plan projects; our Free plan test project showed it. If you are on Free, set the minimum password length to at least 8, require mixed character types, turn on CAPTCHA, note the warning as accepted, and turn the toggle on when you upgrade. The Supabase Auth settings guide and login brute force protection cover the rest of the login defenses.

Schedule these: hardening WARN findings

Function Search Path Mutable (0011)

This is the most common warning on AI-built projects, often on small trigger functions like touch_updated_at. A function without a fixed search_path resolves unqualified names (profiles rather than public.profiles) using the caller's settings. The lint docs explain the risk: someone could "direct the function to use unexpected objects, such as tables or other functions, that the malicious user controls." That matters most for SECURITY DEFINER functions, which run with the owner's privileges.

How urgent is it? The PostgreSQL schemas docs say that on Postgres 15 and later the default configuration no longer lets every role create objects in public, but a database upgraded from an older version may still allow it. Check your own project in the SQL editor with select has_schema_privilege('anon', 'public', 'create'), has_schema_privilege('authenticated', 'public', 'create');. If both are false, a plain invoker trigger function with this warning is hardening, not an open door. Fix it anyway, because it takes one line and it makes your definer functions safe:

alter function public.touch_updated_at() set search_path = '';

Include the argument types for functions that take arguments, for example alter function public.is_pro(uuid) set search_path = '';. Then check the body. With an empty search path, every table, view, and function in it must be schema qualified. Built-ins such as now() still work because pg_catalog is always searched. We confirmed on Postgres 17 that a function reading an unqualified notes table failed with relation "notes" does not exist after the change, and worked again once the body said public.notes.

One more trap: we also confirmed that create or replace function without a set search_path clause drops the setting. When an AI tool rewrites a function in a new migration, the warning comes back. Put the setting in the definition itself:

create or replace function public.touch_updated_at()
returns trigger
language plpgsql
set search_path = ''
as $$
begin
  new.updated_at := now();
  return new;
end;
$$;

To list every function in public that still lacks it:

select p.oid::regprocedure as function
from pg_catalog.pg_proc p
join pg_catalog.pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
  and not exists (
    select 1 from unnest(coalesce(p.proconfig, '{}')) as c
    where c like 'search_path=%'
  );

Extension in Public (0014)

Extensions such as pg_trgm, vector, or citext installed into public put their functions and tables into your API surface. Supabase's extensions docs say most extensions are installed under the extensions schema. The risk is mostly clutter and unintended functions exposed over the API, so this can wait for a quiet day.

The fix for a relocatable extension is one line:

alter extension pg_trgm set schema extensions;

It cleared the lint on our test database. Three cautions:

  • Calls may need the new schema. Code that calls public.similarity() breaks after the move; extensions.similarity() always works.
  • Not every extension can move. The PostgreSQL ALTER EXTENSION docs say it "has to be relocatable for this command to succeed." Otherwise you drop and recreate it, which can remove dependent indexes and columns.
  • Never run drop extension ... cascade on a live database. Try it on a branch or local copy first, after a backup.

Extension versions outdated (0022)

Run alter extension <name> update; after reading that extension's changelog and testing outside production. The lint docs note that Supabase's SLA for extension issues covers only the default version.

GraphQL schema exposure (0026, 0027)

If pg_graphql is installed, any table anon or authenticated can select shows up, with column names, in /graphql/v1 introspection, even when RLS returns zero rows. If your app does not use GraphQL (most vibe-coded apps use supabase-js against the REST API), the lint docs' simplest fix is drop extension pg_graphql;. If you do use it, revoke select from anon and authenticated on tables those roles never need.

Insufficient MFA options (auth_insufficient_mfa_options)

This finding points at Authentication, Multi-Factor. Supabase does not document the exact threshold. It is about which MFA methods your project offers, not whether your users use them. Supabase's TOTP docs say app authenticator (TOTP) MFA "is free to use and is enabled on all Supabase projects by default." Phone MFA is a paid add-on; Supabase's billing page lists it at $75 per month for the first project.

What protects your app: keep TOTP enabled, add an enrollment screen, and require MFA for admin accounts with a restrictive policy on (select auth.jwt()->>'aal') = 'aal2', as Supabase's MFA guide shows. Do not buy an add-on just to clear the badge. And turn on MFA for your own Supabase login, which controls the whole database.

Postgres version has security patches available (vulnerable_postgres_version)

The Advisor says your Postgres build "has outstanding security patches available." The fix is an upgrade, not SQL. Supabase's upgrading docs say to use the Upgrade project button on the General settings page, that "your project is taken offline" while it runs, and that a failed upgrade brings the original database back. Pick a quiet window, take a backup first, check the prerequisites (read replicas, reg* column types flagged by lint 0018), and test the app afterwards. Do it within weeks, not months.

INFO findings worth a look

RLS Enabled No Policy (0008)

RLS is on and no policy exists, so the Data API returns nothing for that table. That is safe, and a bug only if your app needs the data. It is correct for a table only your server touches with the secret key, such as billing_events written by a Stripe webhook. The lint docs suggest making that intent explicit with a deny policy, which also cleared the finding on our test database:

create policy "No API access"
on public.billing_events
for all
to anon, authenticated
using (false)
with check (false);

If the frontend does need the table, write real policies as in the 0013 section.

Anonymous sign-ins allowed (0012)

With anonymous sign-ins on, anonymous users get the authenticated role. Any policy that says only to authenticated lets them in. Where that is wrong, add a check on the JWT's is_anonymous claim, as Supabase's anonymous sign-ins docs describe.

Re-run the Advisor and keep it green

After you apply fixes, refresh the Security Advisor or run supabase db advisors --linked --type security. Then keep it green:

  • Make every fix a migration, not a hand edit in the dashboard.
  • Run the advisor after each migration your AI tool writes. In CI, supabase db advisors --local --type security --fail-on error fails the build on any new ERROR.
  • Write down accepted findings (an intentional public function, a public catalog) and why.
  • Test as anon and as two users, with the transactions above or pgTAP tests run by supabase test db.

The Advisor checks the database. It cannot see a secret key in your frontend, which bypasses every policy on this page. Check your public repo with the free VibeLocked secret scanner and read anon key vs service role key if you are not sure which key your app ships.

Checklist

  • Security Advisor shows no ERROR findings
  • Every public table has RLS on, grants set per role, and one policy per operation
  • Tested as anon and as two signed-in users; nobody sees another user's rows
  • No view in public without security_invoker; no view over auth.users
  • No policy reads user_metadata
  • Every SECURITY DEFINER function has EXECUTE revoked from public, anon, and authenticated unless it is meant to be public
  • No using (true) policy except deliberate public reads
  • Email OTP expiration is 3600 seconds or less
  • Leaked password protection on (Pro plan), or minimum length, character rules, and CAPTCHA on Free
  • Functions set search_path = '' in their definitions
  • Extensions live in the extensions schema
  • Postgres upgrade scheduled if the Advisor reports patches
  • Advisor runs after every migration, with accepted findings written down

Then run the ship checklist for a vibe-coded app before you share the link.

Copy-paste prompt for your AI coding tool

Paste this into Cursor, Claude Code, Lovable, or Bolt. Paste the advisor findings (lint names and object names), never keys, passwords, or production data.

Here are my Supabase Security Advisor findings: [paste lint names and object names only]. For each one, explain the risk in one sentence, then write one new SQL migration file that fixes it without editing old migrations. Rules: when enabling RLS, in the same migration revoke all from anon and authenticated, grant back only what the app needs, and add one policy per operation using (select auth.uid()) with to authenticated, plus with check on insert and update. Never use using (true) except for data I confirm is public. For views use security_invoker = on. For SECURITY DEFINER functions revoke execute from public, anon, and authenticated unless I say the function is public. For mutable search_path add set search_path = '' and schema qualify every name in the body. Do not use drop ... cascade. List every frontend query that will return empty results after these changes. Do not run anything against production; show me the migration first.

Review the migration yourself before it runs, and test it on a branch or local database first. More prompts for reviewing AI-written code are in the security review prompt guide.

Sources

Questions

Which Supabase Security Advisor warnings should I fix first?

Fix every ERROR first, starting with rls_disabled_in_public (0013), sensitive_columns_exposed (0023), security_definer_view (0010), auth_users_exposed (0002), and rls_references_user_metadata (0015). Those mean data may be readable or writable over the API right now. Next fix the WARN findings that are real exposure, such as SECURITY DEFINER functions anon can call (0028, 0029) and policies that always return true (0024). Mutable search_path, extensions in public, MFA options, and the Postgres version warning can go on a schedule.

Will enabling RLS break my Supabase app?

It can. Supabase's docs say that once RLS is enabled, no data is accessible through the API with the publishable or anon key until you create policies. If your frontend reads that table, it will start getting empty results. Write a select policy (and insert, update, delete policies if the app writes) in the same migration that enables RLS, then test as anon and as two different signed-in users.

How do I fix function_search_path_mutable in Supabase?

Run alter function public.your_function(arg types) set search_path = ''; for each flagged function, then make sure every table and function inside the body is schema qualified, such as public.profiles. If a name is unqualified the function fails with relation does not exist. A later create or replace function without the set clause removes the setting again, so put set search_path = '' in the function definition in your migrations.

Is leaked password protection available on the Supabase Free plan?

No. Supabase's password security docs say leaked password protection is available on the Pro Plan and above. The Security Advisor can still show auth_leaked_password_protection on a Free plan project; we saw it on one. On Free, set a minimum password length of at least 8, require mixed character types, and add CAPTCHA, then record the warning as accepted until you upgrade.

Is supabase db lint the same as the Security Advisor?

No. supabase db lint runs plpgsql_check to find typing errors in your database functions. The Security Advisor lints run with supabase db advisors (for example supabase db advisors --linked --type security), with the get_advisors tool in the Supabase MCP server, or in the dashboard under Advisors, Security Advisor.