Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 7 min read

Reader Comments Shaped Three Releases of supabase-rls-check

On November 10, 2026, I published "Five Supabase RLS Mistakes a Read-Only Query Can Catch" on dev.to. That post introduced supabase-rls-check, my open-source Python CLI and read-only PostgreSQL 15+ catalog query (check.s

On November 10, 2026, I published "Five Supabase RLS Mistakes a Read-Only Query Can Catch" on dev.to. That post introduced supabase-rls-check, my open-source Python CLI and read-only PostgreSQL 15+ catalog query (check.sql) that inspects pg_catalog and storage.buckets for Row Level Security mistakes.

Readers began posting comments with edge cases from their own schemas. Within about a day, I shipped versions 0.4.0 and 0.5.0 on November 10, then followed up with version 0.6.0 on November 11. Those releases brought check.sql from 13 rules to 17. They also added run-to-run diffing for CI and local secret scanning for coding agent config files.

Policies that allow any signed-in user

In the discussion on the November 10 post, sunnatulla_dev pointed out a policy pattern that passes basic RLS enablement checks. A developer enables RLS on a table in public and writes a policy that verifies the caller has a session, using auth.role() = 'authenticated' or auth.uid() IS NOT NULL.

In a Supabase project with open sign-ups, any person who creates an account satisfies that check and can access all rows covered by the policy because the expression omits a row ownership check against a column like user_id. In version 0.4.0, I added the policy_any_authenticated rule at Medium severity for USING and WITH CHECK clauses on tables in public.

PostgreSQL stores policy expressions inside pg_catalog.pg_policies. Because developers wrap auth.uid() in (SELECT ...) for planner caching and Postgres adds ::text casts or aliases when deparsing SQL, check.sql strips those wrappers before comparing the normalized string. This simplified query from check.sql shows the core check:

-- Simplified from check.sql (the full query also strips SELECT wrappers, aliases, and ::text casts)
SELECT 'policy_any_authenticated' AS rule, schemaname, tablename, policyname
FROM pg_catalog.pg_policies
WHERE schemaname = 'public'
  AND lower(regexp_replace(coalesce(qual, ''), '[()[:space:]]', '', 'g'))
      IN ('auth.role=''authenticated''', 'auth.uidisnotnull');

Writable USING (true) policies paired with table grants

Earlier versions already had a policy_true rule that flagged any policy in public where USING or WITH CHECK is just true. A LinkedIn reader pointed at the combination that does the most damage: USING (true) on an UPDATE or DELETE policy while the client roles also hold write grants on the table. Anyone with the anon key can then change or delete every row.

In version 0.5.0, I shipped policy_true_writable as a Critical rule. The full query in check.sql checks pg_catalog.pg_policies for permissive UPDATE or DELETE policies (and ALL policies) in public where the normalized USING clause is true for anon or authenticated (or public). It then calls pg_catalog.has_table_privilege to confirm that anon or authenticated holds UPDATE or DELETE grants on that table. This simplified version of the check.sql rule illustrates the join:

-- Simplified from check.sql
SELECT 'policy_true_writable' AS rule, p.schemaname, p.tablename, p.policyname
FROM pg_catalog.pg_policies p
JOIN pg_catalog.pg_class c ON c.relname = p.tablename
WHERE p.schemaname = 'public' AND p.permissive = 'PERMISSIVE'
  AND p.cmd IN ('UPDATE', 'DELETE', 'ALL')
  AND regexp_replace(coalesce(p.qual, ''), '[()[:space:]]', '', 'g') = 'true'
  AND pg_catalog.has_table_privilege('anon', c.oid, 'UPDATE');

Diffing runs in CI and putting the severity basis into the finding key

Reader launchgatecheck contributed two ideas across versions 0.5.0 and 0.6.0. First, launchgatecheck suggested running supabase-rls-check in CI and diffing the output between runs so teams with existing findings can block new problems in pull requests while fixing older warnings over time. In version 0.5.0, I added the diff CLI command and a diff_results(old_path, new_path) MCP tool to compare two local CSV or JSON exports and report new or resolved findings with their severities. Passing --fail-on-new fails the CI job if a new finding reaches your chosen severity threshold.

After 0.5.0 shipped, launchgatecheck asked what diff does when a finding's severity basis changes between runs without the finding itself appearing or disappearing. In version 0.3.0, following an earlier dev.to reader comment, I had updated sensitive_anon_policy to check for a pii table comment tag (COMMENT ON TABLE public.x IS 'pii') alongside its table-name regular expression. Keying findings by (rule, schema_name, object_name, policy_name) meant a finding whose underlying reason changed or escalated between runs kept the same identity key.

In version 0.6.0, I added an optional basis column to check.sql and expanded the diff identity key to (rule, schema_name, object_name, policy_name, basis) while keeping backward compatibility with older JSON files. If a finding's trigger reason changes, diff reports the old finding as resolved and flags the new basis as a fresh finding. In check.sql, sensitive_anon_policy builds basis with a CASE expression:

-- Simplified basis column from sensitive_anon_policy in check.sql
CASE
  WHEN pg_catalog.obj_description(c.oid, 'pg_class') ~* '\mpii\M'
    THEN 'table comment tag ''pii'''
  ELSE 'table name matches pattern '''
    || substring(lower(p.tablename) FROM '(user|profile|order|payment|invoice|customer|patient|secret|token|document)')
    || ''''
END AS basis

SECURITY DEFINER functions that trust a user_id argument

Another reader, avivgerzhue, brought up SECURITY DEFINER RPC functions that trust an identifier argument from the caller. Because a SECURITY DEFINER function runs with the privileges of its owner, queries inside the function bypass RLS on underlying tables. If a function executable by anon or authenticated takes a user_id or tenant_id argument without checking auth.uid() or auth.jwt(), a client can call /rest/v1/rpc with another user's UUID.

In version 0.6.0, I added definer_trusts_id_arg at Medium severity and marked it for human review. In check.sql, the query inspects pg_catalog.pg_proc for non-trigger SECURITY DEFINER functions in public executable by anon or authenticated. It filters input arguments in proargnames and proargmodes against (_id$|^uid$|^user$|^org$|^tenant$) for the basis column, and it checks that prosrc omits auth.uid() and auth.jwt(). This simplified query from check.sql shows the check:

-- Simplified from check.sql
SELECT 'definer_trusts_id_arg' AS rule, p.proname, p.proargnames AS basis
FROM pg_catalog.pg_proc p
WHERE p.prosecdef AND p.prokind = 'f'
  AND array_to_string(p.proargnames, ',') ~* '(_id|uid|user|org|tenant)'
  AND p.prosrc !~* '\mauth[[:space:]]*\.[[:space:]]*(uid|jwt)[[:space:]]*\([[:space:]]*\)'
  AND pg_catalog.has_function_privilege('authenticated', p.oid, 'EXECUTE');

Because definer_trusts_id_arg relies on argument names and a regular expression over prosrc, it has false positives and false negatives and requires human review. A function that accepts a public record identifier such as product_id or delegates caller checks to a helper function will produce a false positive. A function that names its caller parameter caller_account or mentions auth.uid() inside a comment while still trusting the input argument will produce a false negative.

Exposed secret columns and coding agent config files

Version 0.6.0 added two more checks for leaked secrets. In check.sql, secret_column_exposed (Medium) checks tables in public for columns matching api_key, apikey, secret, client_secret, access_token, refresh_token, private_key, or password (exact or with an underscore prefix) where anon or authenticated holds table or column SELECT grants, putting the column name into basis:

-- Simplified from check.sql
SELECT 'secret_column_exposed' AS rule, c.relname AS object_name, a.attname AS basis
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_attribute a ON a.attrelid = c.oid
WHERE a.attnum > 0 AND NOT a.attisdropped
  AND a.attname ~* '(^|_)(api_key|apikey|secret|client_secret|access_token|refresh_token|private_key|password)$'
  AND pg_catalog.has_column_privilege('anon', c.oid, a.attnum, 'SELECT');

In scan-env and the scan_public_env(path) MCP tool, I added agent_config_secret (Critical). This check followed a November 2026 Ars Technica report about Claude Code settings files leaking secrets on GitHub. Running supabase-rls-check scan-env . scans .claude/settings.local.json and .claude/settings.json along with .cursor/mcp.json and .mcp.json (plus .vscode/mcp.json) for sb_secret_ keys and service_role JWTs, as well as PostgreSQL connection strings with inline passwords, masking all secret values.

Limits of the tool

You should keep the boundaries of static catalog analysis in mind when reviewing a report. The queries in check.sql read catalog metadata from pg_catalog and storage.buckets without reading table data, so the tool cannot see row contents or business context. Several rules depend on heuristics such as regular expressions over identifier names and function bodies, which means custom naming can miss a rule and safe code can trigger a warning that needs manual inspection. Running a catalog check and a config scanner does not replace a penetration test or an application security review.

Trying it on your project

You can install supabase-rls-check with pipx and print the bundled read-only SQL query to inspect or run in the Supabase SQL Editor. Once you export two runs as CSV or JSON, you can compare them with diff:

pipx install supabase-rls-check
supabase-rls-check sql --print-sql
supabase-rls-check diff --old a.json --new b.json --fail-on-new high

Do you have another Supabase RLS or Postgres privilege mistake in your review checklist that a read-only catalog query could catch?

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.