DEV Community

Cover image for I Lint-Ran 4 Public Supabase Apps. They All Leak Rows.
Dmitry Maranik
Dmitry Maranik

Posted on

I Lint-Ran 4 Public Supabase Apps. They All Leak Rows.

I ran pgrls — an open-source
Postgres Row-Level Security linter I maintain — against four public
Supabase-flavored repos. Three of them are first-party examples or
official partners:

Repo Who maintains it Findings
supabase/storage-api Supabase 2
vercel/nextjs-subscription-payments Vercel × Stripe × Supabase 30
supabase/supabase/examples/slack-clone Supabase 49
supabase/supabase/examples/todo-list Supabase 15
Total 96

Zero false positives across the 96 findings. Every flagged
policy is either a real bug or a known-acceptable trade-off worth
surfacing for review.

What "correct" looks like first

Supabase's own RLS guide
is direct about it. Under
"Specify roles in your policies" (in the RLS-performance
recommendations section) the docs say:

Always use the Role of inside your policies, specified by the
TO operator.

And demonstrate the pattern for anonymous access as a separate,
explicit
policy, not a default-on side effect:

create policy "Public profiles are visible to everyone."
    on profiles for select
    to anon                          -- explicit anon allow
    using ( true );
Enter fullscreen mode Exit fullscreen mode

The translation in practice:

-- Logged-in users read messages:
CREATE POLICY "auth read" ON public.messages
    FOR SELECT TO authenticated      -- explicit role binding
    USING (true);

-- Anonymous reads, only if you actually want them:
CREATE POLICY "anon read" ON public.messages
    FOR SELECT TO anon               -- separate, intentional
    USING (is_public = true);
Enter fullscreen mode Exit fullscreen mode

The standard isn't load-bearing because it's elegant — it's
load-bearing because the alternative ships rows to anonymous
clients by accident. A policy without an explicit TO clause
defaults to TO public, which means every role on the database,
including anon. The 96 findings are what falls out when four
sample apps are measured against this single principle and the
small handful of related ones (FORCE the owner role, scope by
auth.uid() not current_user, wrap auth calls for the planner).

The four rules that fire on every repo

Across all four codebases, the same four rules appear:

Rule What it catches Why it's everywhere
SEC002 RLS enabled but FORCE ROW LEVEL SECURITY missing The owner role (typically the migration role) bypasses RLS until you opt into FORCE. Default-off in Postgres.
SEC003 Permissive policy with no TO <role> clause Defaults to TO public, which reaches anonymous (anon) connections by accident.
SEC007 All policies on a table are permissive A single typo in RESTRICTIVE (or forgetting it entirely) collapses the OR-chain to "anyone matching ANY policy passes."
SEC016 service_role has BYPASSRLS True by design on Supabase, but surfaced so an operator can audit the blast radius before granting it to anything else.

These four are the default-Supabase posture findings. Nothing
exotic. Every Supabase project has them on day one.

What the deviation looks like in practice

The densest single finding is SEC003 in slack-clone — 13
instances
of the same shape. From
examples/slack-clone/nextjs-slack-clone/full-schema.sql#L87:

create policy "Allow logged-in read access" on public.messages
    for select
    using ( auth.role() = 'authenticated' );
Enter fullscreen mode Exit fullscreen mode

No TO <role> clause, so the policy defaults to TO public. The
predicate (auth.role() = 'authenticated') catches it at runtime
today, but the policy still runs for anon connections — any
later predicate change (a misplaced OR, a NULL-tolerant
IS DISTINCT FROM, the SEC004 auth_func() IS NULL OR … shape)
immediately leaks every message to unauthenticated callers.

The fix is moving the role gate from the predicate into the TO
clause, where it belongs:

CREATE POLICY "Allow logged-in read access" ON public.messages
    FOR SELECT TO authenticated         -- ← gate here, not in USING
    USING (true);
Enter fullscreen mode Exit fullscreen mode

13 policies in slack-clone follow the broken shape. 5 do in
nextjs-subscription-payments. 4 in todo-list. Same fix line each
time.

The performance footgun: PERF001 in 19 of the 4 repos

USING (owner_id = auth.uid())          -- re-evaluates per row
USING (owner_id = (SELECT auth.uid())) -- evaluated once per query
Enter fullscreen mode Exit fullscreen mode

Supabase's own RLS docs recommend the
(SELECT auth.uid()) wrap

for performance reasons — a row-by-row auth.uid() call is
quadratic-ish on a large SELECT, while the wrapped form lets the
planner evaluate once. 19 policies across the four repos miss
this wrap.
pgrls flags it as PERF001 and auto-fixes it.

What 0 false positives means

I don't think most lint tools earn the "0 false positives" claim
honestly. Two things make pgrls's count credible:

  1. It analyzes the live database catalog, not source files. The policy that fires is the policy Postgres will actually enforce — not a guess at what the migration text resolves to.
  2. Each rule is severity-rated and configurable. "False positive" in the strictest sense means "the rule flags something that's not a bug." Across these 96 findings, every flagged policy either is a bug or has a known-acceptable trade-off (e.g., service_role's BYPASSRLS on Supabase is intentional — but pgrls.toml's allowlist lets the operator mark it as acknowledged rather than silently ignored).

If a finding doesn't apply to your project, it's an allowlist
entry, not a bug in the linter:

[lint.rules.SEC016]
allowlist = ["service_role"]
Enter fullscreen mode Exit fullscreen mode

Try it against your own schema

pip install pgrls
export DATABASE_URL='postgres://…'
pgrls lint --schemas public
Enter fullscreen mode Exit fullscreen mode

If you're on Supabase, the dev workflow is:

supabase start
DATABASE_URL=$(supabase status -o env | grep DB_URL | cut -d= -f2)
pgrls lint --schemas public
Enter fullscreen mode Exit fullscreen mode

Or as a one-step GitHub Action:

- uses: pgrls/pgrls-action@v1
  with:
    database-url: ${{ secrets.SUPABASE_DB_URL }}
    schemas: public
Enter fullscreen mode Exit fullscreen mode

If you share a (sanitized) policy that pgrls misses or
misclassifies, the catalog grows from real schemas, not synthetic
test fixtures. Drop a thread at
github.com/pgrls/pgrls/discussions.


47 rules total, 12 with mechanical auto-fixes. MIT,
framework-agnostic, runs against any Postgres 15+. Source:
github.com/pgrls/pgrls. Docs:
pgrls.github.io/pgrls-docs.

Top comments (2)

Collapse
 
saltcod profile image
Terry Sutton

Cool project! Reach out if you've got ideas for how we could incorporate this into the product.
terry at supabase dot io.

Collapse
 
dmitrymaranik profile image
Dmitry Maranik

Thanks Terry! Will send you a note. Off the top of my head there
are a few angles worth talking about — supabase CLI integration,
surfacing findings in the dashboard's policy editor, or a docs
recipe. Happy to start with whichever fits your team's current
priorities best.