← All posts
·8 min read·GuardLayer

supabase db advisors: run Security Advisor in CI

SupabaseSecurity AdvisorCLICIGitHub Actions

Run supabase db advisors --type security --fail-on error in CI. It runs the Security Advisor's lints and exits with status 1 when any of them comes back at ERROR level, so a table with RLS off or a view that skips RLS fails the pipeline instead of waiting for someone to open the dashboard. Against a local stack it needs only supabase start. Against your hosted project it needs --remote and a SUPABASE_ACCESS_TOKEN secret. supabase db lint is a different tool: it type-checks PL/pgSQL functions and doesn't run any Security Advisor lint.

The Security Advisor is a page you have to remember to open. For what each lint means, see every Supabase Security Advisor warning explained. This post gets the same lints into a pipeline, with the flags checked against the CLI source.

How do I run the Supabase Security Advisor from the CLI?

You run supabase db advisors, which Supabase added in CLI v2.81.0 (March 2026). With no flags it checks the local database for both security and performance lints and prints anything at WARN or above as a JSON array. These are the flags, as declared in advisors.command.ts in CLI v3.0.0:

FlagValuesDefaultHelp text
--typeall, security, performanceall"Type of advisors to check: all, security, performance."
--levelinfo, warn, errorwarn"Minimum issue level to display: info, warn, error."
--fail-onnone, info, warn, errornone"Issue level to exit with non-zero status: none, info, warn, error."

--remote picks the database. Leave it out for the local stack, pass it bare for the linked project, or give it a project ref, branch name or postgresql:// URL (--remote=<ref>).

--fail-on defaults to none, so a plain supabase db advisors exits 0 whatever it finds. That's why a CI step can "run the advisor" for months and never fail.

The target flags just changed. CLI v3.0.0 (November 2, 2026) moved targeting to --remote. The 2.x flags --local, --linked and --db-url still work in v3, but they're hidden, print a deprecation warning, and are marked for removal in v4. On 2.x, use --linked and --db-url. Either way, pin the CLI version in CI.

supabase db lint vs supabase db advisors

They sound like the same thing. They aren't.

  • supabase db lint runs the plpgsql_check extension over your PL/pgSQL functions and reports typing and schema errors in function bodies: a column that doesn't exist, a wrong type. Its levels are warning and error, so its fail flag is --fail-on warning.
  • supabase db advisors runs splinter, Supabase's database linter, which is what the Security and Performance Advisors show. Its levels are info, warn and error, so its fail flag is --fail-on warn.

So --fail-on warning works for db lint and is rejected by db advisors, and warn is the reverse. Neither command writes to your database.

Local or remote: what each one actually checks

The command has two backends, and the CLI's own notes for it spell out the difference.

Local (the default, or --remote=<postgres url>) connects to the database and runs a copy of splinter's SQL that ships inside the CLI. It takes the exposed schemas from [api].schemas in supabase/config.toml and needs no token. On a fresh CI runner, supabase start creates the database and applies your migrations, then seed.sql.

Remote (bare --remote for the linked project, or --remote=<ref>) doesn't run any SQL itself. It calls the Management API, GET /v1/projects/{ref}/advisors/security (and /performance), which the API reference marks as experimental. It needs an access token, and it sees changes no migration made, like a grant someone ran in the SQL Editor.

One gap matters for security. The SQL bundled in CLI v3.0.0 contains lints 0001 to 0024, and nothing after. The splinter repo now has 0025 to 0030, including 0028 and 0029, SECURITY DEFINER functions that anon or authenticated can execute. A local run can't report those. If you rely on them, run the remote check too.

A GitHub Actions workflow that fails on Security Advisor errors

Two jobs. The first checks every pull request against a throwaway local stack built from your migrations. The second checks the hosted project after merges to main and once a day.

name: supabase-advisors

on:
  pull_request:
  push:
    branches: [main]
  schedule:
    - cron: "0 6 * * *"

jobs:
  advisors-local:
    if: github.event_name != 'schedule'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: supabase/setup-cli@v3
        with:
          version: 3.0.0
      - run: supabase start
      - run: supabase db advisors --type security --fail-on error

  advisors-remote:
    if: github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    env:
      SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
      SUPABASE_PROJECT_ID: ${{ vars.SUPABASE_PROJECT_ID }}
    steps:
      - uses: actions/checkout@v4
      - uses: supabase/setup-cli@v3
        with:
          version: 3.0.0
      - run: supabase db advisors --remote --type security --fail-on error

A few things in there are deliberate.

  • The exit code. When a lint at or above --fail-on is in the output, the CLI prints the results, then fails with fail-on is set to error, non-zero exit and exit status 1. Anything below the threshold exits 0. A connection failure, a missing token or a non-200 from the advisors API also exits 1, so a broken setup can't pass silently.
  • SUPABASE_PROJECT_ID instead of supabase link. A bare --remote reads the project ref from the link file, and skips that file when SUPABASE_PROJECT_ID is set. The ref goes in a repository variable. The token goes in a secret.
  • The remote job doesn't run on pull requests. GitHub doesn't pass secrets to workflows triggered from forks, and a PR hasn't changed the hosted database yet anyway. Don't reach for pull_request_target to get the token into fork PRs. That's the pwn-request pattern.
  • setup-cli@v3 installs the CLI from npm and provisions Node.js 20+ if the runner lacks it. version: 3.0.0 keeps the flags you tested. Use version: ^2 with --linked if you're staying on 2.x.

Failing the job doesn't stop a merge by itself. To block merges, make advisors-local a required status check in your branch protection rules.

Pick the fail level carefully

Start with --fail-on error. The ERROR lints are the ones that expose data: 0002 Exposed Auth Users, 0007 Policy Exists RLS Disabled, 0010 Security Definer View, 0013 RLS Disabled in Public, 0015 RLS references user metadata, 0019 Insecure Queue Exposed in API, 0021 Foreign Key to Auth Unique Constraint, and 0023 Sensitive Columns Exposed.

warn pulls in lints you may not control. 0022 Extension Versions Outdated fires when an installed extension isn't at the server's default version, which can change when the Postgres image is upgraded rather than when your code changes. Move to --fail-on warn once your WARN list is clean.

--level filters before --fail-on is checked. The handler checks --fail-on against what survived --level. So --level error --fail-on warn never fails on a WARN, and --fail-on info with the default --level warn never fails on INFO lints like 0008 RLS Enabled No Policy.

0013 needs a grant to fire. The lint only reports a table with RLS off if anon or authenticated holds SELECT on it. Since the October 30, 2026 Data API change, new tables in public on existing projects get no automatic grants, so a fresh table with RLS off may not trip 0013. It trips the moment a migration grants it, like the one below.

What it catches, and what it doesn't

Here's the migration that grant produces. Someone adds a waitlist table, hits permission denied, and grants it to the API roles:

guardlayer scan · supabase/migrations/20261103090000_waitlist.sqlLive engine output
Check failed
67/100 · C
  • Criticalsupabase/migrations/20261103090000_waitlist.sql:10

    Table or view granted to the anon role without RLS

    For a table, enable RLS (ALTER TABLE <t> ENABLE ROW LEVEL SECURITY;) and add scoped policies. For a view, create it WITH (security_invoker = true) and repeat that clause on every CREATE OR REPLACE VIEW. Materialized views and foreign tables cannot be protected: revoke the anon grant or move them to a schema the Data API does not expose. Prefer per-table grants over ON ALL TABLES IN SCHEMA public. Only keep an anon grant for genuinely public data that is still RLS-protected.
  • Warningsupabase/migrations/20261103090000_waitlist.sql:3

    Table created without enabling RLS

    Add ALTER TABLE <table> ENABLE ROW LEVEL SECURITY; plus access policies right after the CREATE TABLE, and grant anon / authenticated only the privileges the app needs, per table, after RLS is on. A later migration in the same scan that enables RLS (or drops the table) clears this warning. It still shows when the enable is in a file whose run order is unknown (a schema.sql with no version number) or not part of this scan (a pull-request scan sees only the files the PR changes).

GuardLayer flags two things in the file: the create table never enables RLS, and the grant hands the table to anon with nothing in front of it. Run the workflow above on the PR and the local job fails too, on lint 0013. Once deployed, anyone holding your anon key can read every email on the list. The fix is two lines before the grant, alter table public.waitlist enable row level security; plus a policy for what anonymous callers may do.

Each tool reads something different, so each catches something different.

  • supabase db advisors reads a database. It needs a running Postgres with your migrations applied, and its local mode stops at lint 0024.
  • GuardLayer's static scanner reads the migration files and your app code on each push and PR, without a database,, and posts a check on the PR. A SECURITY DEFINER function with no pinned search_path shows up in both, but a service role key in a client bundle or a server action with no auth check is only visible in the code.
  • Live check reads your live project's catalog, read-only, and gives a paste-ready SQL fix for each finding, including SECURITY DEFINER functions anyone can call and views that skip RLS.

None of them tests whether your policies are correct. For that, write RLS tests and run them in the same workflow with supabase test db.

FAQ

Does supabase db advisors run the same checks as the Security Advisor in Studio? The remote mode fetches advisor results from Supabase's Management API instead of running its own SQL. The local mode runs splinter's SQL bundled in the CLI, which in v3.0.0 covers lints 0001 to 0024 and not the newer 0025 to 0030.

Why does supabase db advisors exit 0 when it finds problems? Because --fail-on defaults to none. Pass --fail-on error (or warn) to get exit status 1. Check --level too: a lint it hides can't trigger --fail-on.

What's the difference between supabase db lint and supabase db advisors? db lint runs plpgsql_check and finds errors inside PL/pgSQL function bodies. db advisors runs the Security and Performance Advisor lints: RLS off, views that skip RLS, exposed auth.users and the rest.

Is --linked deprecated? In CLI v3.0.0, yes. --linked, --local and --db-url still work but print a warning, and their help text says they're removed in v4. Use bare --remote for the linked project or --remote=<ref> for another one.

Can I run it on a schedule? Yes. Add a schedule trigger to the remote job. That catches tables and grants made in the SQL Editor, which never pass through a migration or a PR.

See which of your Supabase tables anyone can read, free

Connect your Supabase project read-only. GuardLayer checks RLS, policies, grants and functions as they are right now, and gives you the SQL to fix each problem. It never reads your rows and can't change anything.

Check my Supabase project

Prefer to check code instead? Scan your repo, no signup.

Keep reading