Security and Performance Advisors for Self-Hosted Supabase

Supabase Advisors don't ship with self-hosted Studio. Run the same open-source Splinter lints against your own instance — setup, triage, and automation.

Cover Image for Security and Performance Advisors for Self-Hosted Supabase

Open the Supabase Cloud dashboard and you get two tabs self-hosters never see: Security Advisor and Performance Advisor. They flag tables with RLS disabled, views that leak auth.users, foreign keys with no index — the kind of problems that don't announce themselves until an incident does. Self-hosted Studio doesn't include them, and it's one of the gaps we called out in what features are missing in self-hosted Supabase.

Here's the part most self-hosters don't know: the Advisors are just SQL. Supabase publishes every lint as an open-source project called Splinter, and you can run the exact same checks against your own instance with psql. This guide covers how to run them, which findings actually matter, one caveat that silently hides results on self-hosted setups, and how to automate the whole thing.

What the Advisors Actually Are

When Supabase launched the Advisors, they built them as a set of Postgres queries rather than a proprietary scanner. Each lint inspects the system catalogs — pg_class, pg_policy, pg_index, and friends — and returns findings as rows. The dashboard is just a UI over the result set.

Each finding includes a name, a level (ERROR, WARN, or INFO), categories (SECURITY or PERFORMANCE), a human-readable detail, and a remediation URL pointing at Supabase's docs for that specific lint.

The lints that matter most in production:

LintLevelWhat it catches
rls_disabled_in_publicERRORTables exposed via the Data API with no RLS at all
policy_exists_rls_disabledERRORPolicies written, but RLS never enabled — policies do nothing
auth_users_exposedERRORViews or tables exposing auth.users to anon/authenticated
security_definer_viewERRORViews that bypass the querying user's RLS
function_search_path_mutableWARNFunctions vulnerable to search-path hijacking
auth_rls_initplanWARNPolicies calling auth.uid() per-row instead of once per query
unindexed_foreign_keysINFOFKs that force sequential scans on joins and cascades
multiple_permissive_policiesWARNStacked permissive policies that each run on every query
duplicate_index / unused_indexWARN/INFOWrite overhead with no read benefit

If you've read our complete guide to Row Level Security, you'll recognize that half the security lints are RLS mistakes. That's not a coincidence — misconfigured RLS is the single most common way self-hosted Supabase projects leak data.

Running Splinter Against Your Instance

Splinter ships a single combined query, splinter.sql, containing every lint. Grab it and run it against your database. On a standard Docker Compose deployment:

# Download the latest combined lint query
curl -sLO https://github.com/supabase/splinter/releases/latest/download/splinter.sql

# Copy it into the db container and run it
docker cp splinter.sql supabase-db:/tmp/splinter.sql
docker exec -it supabase-db \
  psql -U postgres -d postgres -f /tmp/splinter.sql

Or from any machine that can reach your database:

psql "postgresql://postgres:<password>@your-server:5432/postgres" \
  -f splinter.sql

You'll get back one row per finding:

name                    | level | categories   | detail
------------------------+-------+--------------+------------------------------------------
rls_disabled_in_public  | ERROR | {SECURITY}   | Table `public.invoices` is public, but
                        |       |              | RLS has not been enabled.
auth_rls_initplan       | WARN  | {PERFORMANCE}| Table `public.orders` has a RLS policy
                        |       |              | that re-evaluates auth.uid() for each row.
unindexed_foreign_keys  | INFO  | {PERFORMANCE}| Table `public.line_items` has a foreign
                        |       |              | key `order_id` without a covering index.

The Supabase CLI also has a db advisors command that wraps these lints, but it's aimed at local CLI-managed projects — for a production server, running splinter.sql directly is simpler and works everywhere psql does.

The Caveat That Hides Findings on Self-Hosted Setups

Several of the most important lints — auth_users_exposed, materialized_view_in_api, foreign_table_in_api — only report objects that are reachable through the Data API. They decide what's "reachable" by reading the pgrst.db_schemas setting, which exists inside PostgREST's connection but not in a plain psql session. Without it, these lints quietly fall back to checking public only.

If you expose additional schemas through PostgREST, mirror your real configuration before running the lints:

-- Match the PGRST_DB_SCHEMAS value from your .env
select set_config('pgrst.db_schemas', 'public,api,storage', false);
\i /tmp/splinter.sql

Check your compose file's PGRST_DB_SCHEMAS value — our environment variables guide covers where each of these settings lives. Getting this wrong doesn't produce an error; it produces a clean-looking report that skipped your API surface entirely, which is worse.

One more Data API note: with Supabase's October 2026 change to Data API grants, new deployments no longer hand broad default privileges to anon and authenticated. The advisors complement that change nicely — grants control whether a role can touch a table, RLS lints verify which rows it can see. You want both layers checked.

Triage: What to Fix First

Don't try to clear the whole report in one sitting. Work it in this order:

  1. ERROR-level security findings, immediately. rls_disabled_in_public and auth_users_exposed are live data exposure, not hygiene. policy_exists_rls_disabled is the sneakiest — someone wrote policies, assumed they were active, and never ran alter table ... enable row level security.
  2. security_definer_view and function_search_path_mutable. These need judgment. Some SECURITY DEFINER views are intentional (that's how you build controlled cross-tenant reads), so verify rather than blindly "fix." For functions, pin the path: alter function my_fn() set search_path = '';
  3. auth_rls_initplan. The highest-leverage performance fix on this list. Wrapping auth.uid() in a subselect — using ((select auth.uid()) = user_id) — makes Postgres evaluate it once per query instead of once per row. On a 100k-row table, that's routinely a 10–100x difference.
  4. Index findings. Add covering indexes for flagged foreign keys and drop true duplicates — our database indexing guide walks through the verification steps before you drop anything.

Automating the Advisors

A lint you run once is a snapshot; the value comes from running it on a schedule and catching regressions when a migration ships. Two patterns work well.

Scheduled run on the server, using plain cron (or pg_cron if you prefer keeping it in-database — see our cron jobs guide):

# /etc/cron.d/supabase-lint — every Monday 06:00
0 6 * * 1 deploy psql "$DB_URL" -f /opt/splinter/splinter.sql \
  --csv -o /var/log/supabase-lint/$(date +\%F).csv

CI gate on migrations, failing the pipeline if any ERROR-level finding appears:

psql "$DB_URL" -f splinter.sql --csv -o results.csv
if awk -F',' '$2 == "ERROR"' results.csv | grep -q .; then
  echo "Security advisor errors found:" && awk -F',' '$2 == "ERROR"' results.csv
  exit 1
fi

This is the same philosophy behind our production hardening guide: security posture on self-hosted infrastructure isn't a one-time checklist, it's a loop.

If you manage several projects, this compounds. Running lints across ten databases by hand doesn't happen in practice — script it against each project's connection string. Supascale's REST API exposes your project inventory, so a single script can enumerate every instance you run and lint them all in one pass; on the one-time license, that works across unlimited projects without per-project fees.

Honest Limitations

The advisors are useful, not sufficient. Know what they don't do:

  • They're point-in-time schema checks. Splinter reads catalogs, not traffic. It will never tell you a query got slow last Tuesday — that's a job for real observability and pg_stat_statements.
  • unused_index lies on young databases. Index usage stats accumulate over time and reset on pg_stat_reset(). An index flagged as unused two days after deployment isn't unused — it's new.
  • Some findings are deliberate. SECURITY DEFINER views and extensions in public are sometimes exactly what you meant. Treat WARN and INFO as questions, not verdicts.
  • Cloud gets the UI; you get the data. There's no pretty dashboard for these results self-hosted. A CSV in CI and a failing pipeline is less polished — and arguably more effective, because it blocks the regression instead of displaying it.

Wrapping Up

The Advisors gap in self-hosted Supabase is smaller than it looks: the entire lint suite is open source, runs anywhere psql runs, and takes about ten minutes to wire into cron or CI. Set pgrst.db_schemas to match your real PostgREST config so the API-facing lints see what your API sees, clear the ERROR-level security findings first, fix auth_rls_initplan for the cheapest performance win you'll get this quarter, and make the run automatic so schema regressions get caught before your users do.

Further Reading