Supabase Security Advisors: What to Fix Before Going Public
A database that works on your laptop can be reachable by anyone once the project is public. The Supabase linter flags the tables, policies and keys that let that happen.

Every hosted Supabase project is a Postgres database with a REST API in front of it. On your laptop you reach the database through a connection that only you hold. In production, the same tables answer HTTP requests from anyone who has the project URL and the publishable key that sits inside your JavaScript bundle. Row-level security is the only thing between those two situations, and the database linter is the cheapest way to find the tables where it is missing.
What changes when the project stops being local
supabase start runs the whole stack on your machine: Postgres, Auth, Storage and the other Supabase features, with Studio at http://localhost:54323. It exists so you can experiment "without affecting your production environment" (local development guide). Nothing in it proves that your access rules are correct, because locally you are the only person making requests.
In a hosted project the Data API is a public endpoint. The RLS guide is direct about a table with no rules in force: "A table in an exposed schema without RLS is readable and writable by any role with a grant on it." Grants do not save you either. On existing projects, "a new table in public starts with every privilege already granted to all three roles", and "adding policies doesn't take those grants back". Grants decide what is reachable. RLS decides who may see a row.
The gap opens in one specific place. According to Securing your API, tables created in the dashboard have RLS on by default, while tables created in the SQL Editor or through another tool need it enabled explicitly. A migration is plain SQL, so a project whose schema is built by migrations can reach production with every table open, and no local test will notice.
What the database linter checks
Supabase calls these checks Advisors: "deterministic checks that ship with the platform" (database linter). They are grouped into Health, Security and Performance, and each check has a number, a name, a title and a level. The security ones you are most likely to meet:
0013_rls_disabled_in_public, "Table publicly accessible", ERROR: no RLS on a table in the public schema, so anyone holding the project URL can view, alter and remove every row.0023_sensitive_columns_exposed, "Sensitive data publicly accessible", ERROR: the API can reach a table of probable secrets or personal identifiers, unrestricted.0015_rls_references_user_metadata, "Security policy relies on user-editable data", ERROR: the policy matches onuser_metadata, which users can edit, so they can step around the rule.0010_security_definer_view, "View bypasses row-level security", ERROR: a public view runs with its owner's rights, skips RLS and can return rows the caller should not see.0002_auth_users_exposed, "User data exposed through a view", ERROR: a view on the auth users table hands personal details to anyone with API access.0024_permissive_rls_policy, "Security policy allows unrestricted access", WARN: an always-true condition cancels out the protection RLS was supposed to provide.0007_policy_exists_rls_disabled, "Security policy not enforced", INFO: a policy is defined but inactive because RLS is off for the table.0008_rls_enabled_no_policy, "No access rules defined", INFO: RLS is on and no policy exists, so the API cannot read or write the table at all.
The list is longer: there are checks for slow policies, for SECURITY DEFINER functions callable without signing in, and for objects outside row-level security such as materialized views. We list the ones that decide whether your first public deploy leaks data.
A worked example: the reports table
Say a migration creates public.reports with a user_id column. Locally you insert a few rows and select them back, and everything works. After deploy, 0013 fires, and the finding is accurate: the table is readable and writable by anyone with the URL and the publishable key.
The RLS guide's fix is one statement:
alter table public.reports enable row level security;
On its own, that changes the failure mode rather than fixing it. With RLS on and no policy, nothing is accessible through the API with a publishable key, which is exactly what 0008 reports. The app stops returning everyone's data by returning no data at all.
The other half is a policy. This is the guide's own example, written for a todos table; the same shape applies to reports once you change the names:
create policy "Individuals can view their own todos." on todos for select to authenticated using ( (select auth.uid()) = user_id );
Two details in it matter. The policy covers select only, so a client that also inserts needs an insert policy of its own. And the (select auth.uid()) form avoids the per-row re-evaluation that 0003 reports: written as a bare function call, the check can run again for every row, which hurts on large tables.
Error, warning and info: what a level tells you
The Advisors page labels every check ERROR, WARN or INFO but does not define the three words. The CLI gives them a mechanical meaning: --level sets the minimum level to display, and --fail-on sets the level that makes the command exit non-zero. Read them as a ranking by loudness, not as a statement about what breaks.
In practice we treat them like this. An ERROR is a path to data that should not exist: rows readable by an anonymous caller, a view or a queue outside RLS, a policy that trusts a field the user controls. Fix it before launch, and if you cannot, know exactly what is exposed. A WARN is a configuration that is probably not what you meant: unrestricted or overlapping policies, something that will not scale. Read each one and decide. INFO is cleanup or a functional bug, and the two INFO findings above are the clearest examples: 0007 means a policy you wrote is doing nothing, and 0008 means the API can reach nothing at all.
0024 deserves its own sentence. It is only a WARN, and it is the finding that quietly undoes the whole exercise: a policy whose condition is always true, for example using (true), gives every caller the same access as having no policy, while the table still looks protected in the dashboard.
Which key belongs in the browser
Supabase now issues two kinds of key, and they are "short strings, not JWTs" (API keys). The publishable key, prefixed sb_publishable_, is documented as "Safe to expose online: web page, mobile or desktop app, GitHub actions, CLIs, source code". The secret key, prefixed sb_secret_, is for "backend components of your app, such as servers, APIs with their own authorization checks". The same page says "Never put one in a browser, a shipped application, or source control", and "A secret key bypasses every Row Level Security policy you have".
That last sentence is the whole design. Every table the publishable key can reach is protected by policies and nothing else, because the key is public by definition: "Anyone can read it, so it only reaches what Row Level Security allows." Policies are the security boundary.
The older names still work: anon is the legacy publishable key and service_role the legacy secret, and Supabase deprecates both by the end of 2026. Policies are written for the anon and authenticated roles, while service_role carries BYPASSRLS and skips them.
Two practical rules from the same page. Keep keys out of source control, including CI scripts, and use the platform's own secret storage. And never give a server key a prefix your bundler treats as public, "or your bundler will ship the key".
One nuance is easy to miss: a secret key bypasses RLS only when the request carries no user access token, so server code acting on behalf of a signed-in user still goes through that user's policies.
How to run the checks
There are three routes: the Security and Performance pages in Studio, the MCP get_advisors tool or the Management API, and the CLI. The linter page documents the command as supabase db advisors. Its help output on CLI 2.100.1 describes it as "Checks database for security and performance issues" and offers --local, --linked or --db-url for the target, --type with all, security or performance, --level with info, warn or error, and --fail-on with none, info, warn or error.
A run against the linked project, before you publish anything:
supabase db advisors --linked --type security --level info
And in CI, the same command with --fail-on error stops a deploy on a finding that exposes data; move the threshold to warn once the team actually reads warnings.
Run it locally too, with --local, on a stack started by supabase start. Local tables come from the same migrations as production, so a clean local run is a decent proxy, with the caveat above.
Know the limits. The linter reads configuration, not intent. It can tell that a policy is unrestricted; it cannot tell that using (auth.uid() = user_id) is wrong because user_id is nullable, or that your insert policy lets one user write another user's id. Treat a clean report as a floor, then read each policy as a sentence about who can do what.
A pre-launch checklist
- RLS is on for every table and view exposed through the Data API, including everything migrations create.
- Every policy you wrote sits on a table where RLS is actually enabled (
0007). - No policy condition is always true (
0024); where one is deliberate, write down why. - Policies match on roles and row columns, never on
user_metadata(0015). SECURITY DEFINERviews and functions are reviewed, andexecuteis granted only to the roles that need it, because RLS does not apply inside functions.- The secret key lives only in server environment variables and CI secret storage; the browser sees the publishable key and nothing else.
supabase db advisors --linked --type securityis clean aterror, and a person has read every warning.- Info findings are read too:
0008means a table is unreachable, not that it is safe. - Backups are settled: on the Free plan they are not available for download, and PITR is the option when the recovery point matters (production checklist).
Where the product fits
Supabase Local Manager runs many local Supabase stacks from one service. The open-source Supabase CLI handles the basic database management, and the manager adds the other services, APIs and frontend, using Docker. Its dashboard includes a security and performance advisors area that lists findings with severity, guidance and a dismissal option. When you keep several projects on one machine, opening each Studio is the step that gets skipped, so having the advisors beside the tables, SQL and migrations is the difference between checking and not checking. It is an independent project, not affiliated with, endorsed by or sponsored by Supabase, Inc., and it is in early access: the source is private and access is requested through GitHub. See supabasedatabasemanager.danubelabs.net.
What we do
We treat the advisors as a gate before deploy: the CLI in CI with --fail-on error, and a person reading the warnings. If you want the access rules of a project checked by someone who did not write them, see our custom software work or book a free intro call. And if your site needs no user data at all, a database-free setup like the one in our git-based CMS article removes the question entirely.
Frequently asked questions
- What is the Supabase database linter?
- Supabase calls these checks Advisors: deterministic checks that ship with the platform. They are grouped into Health, Security and Performance, and each finding has a number, a name and a level. You can read them in Studio, through the get_advisors MCP tool or the Management API, or from the CLI with supabase db advisors.
- What is the difference between the publishable and secret key?
- The publishable key (sb_publishable_) is meant to be public: Supabase documents it as safe to expose in web pages, apps, CLIs and source code, and it only reaches what Row Level Security allows. The secret key (sb_secret_) is server-only, bypasses every RLS policy and must never appear in a browser, a shipped app or source control. The older anon and service_role keys are the legacy versions of the same two.
- What does rls_disabled_in_public mean?
- It is check 0013, an ERROR: a table in the public schema has no RLS, so anyone holding the project URL can view, alter and remove every row. Turn it on with alter table public.your_table enable row level security, then add the policies the API should follow. RLS alone with no policy makes the table unreachable, which the linter reports separately as 0008.
- Is a warning-level finding safe to ignore?
- Not automatically. A warning means the configuration is probably not what you intended, and some of them matter a lot: 0024 flags an always-true policy condition, which gives every caller the same access as having no policy at all. The levels are a ranking of loudness, not a promise about impact, so read each warning and decide. Error-level findings are the ones to fix before launch.
- How do I run the Supabase advisors from the CLI?
- Run supabase db advisors with a target: --local for the stack started by supabase start, --linked for the linked project, or --db-url for a connection string. Add --type security to limit it to security checks, --level to set the minimum level shown, and --fail-on error to make the command exit non-zero when an error is found, which is how you use it as a CI gate.
- Why does my table have RLS locally but not in production?
- Because the two ways of creating a table differ. Supabase enables RLS by default on tables created in the dashboard, while tables created in the SQL Editor or through another tool need it turned on explicitly. A migration is plain SQL, so a schema built entirely from migrations can reach production with every table open while your local testing still passes.


