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
TOoperator.
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 );
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);
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' );
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);
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
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:
- 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.
-
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'sBYPASSRLSon Supabase is intentional — butpgrls.toml'sallowlistlets 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"]
Try it against your own schema
pip install pgrls
export DATABASE_URL='postgres://…'
pgrls lint --schemas public
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
Or as a one-step GitHub Action:
- uses: pgrls/pgrls-action@v1
with:
database-url: ${{ secrets.SUPABASE_DB_URL }}
schemas: public
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)
Cool project! Reach out if you've got ideas for how we could incorporate this into the product.
terry at supabase dot io.
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.