A runnable reproduction of the Next.js + Supabase RLS leak (red on one branch, green on the other, ~2s) #96888
Replies: 1 comment
|
Ran the fixture. Clean repro, and the PGlite choice is right — no credentials Two notes on the write-up, then two cases the fixture doesn't cover that fail On the benchmark number. The 178,000ms → 12ms figure is from
Case 1: Case 2: caching above Postgres. This one is Next-specific and RLS structurally Calling // leaks: cache key doesn't include the caller's identity in any way
// that RLS can see, and the DB is never consulted on the second call
const getDocs = unstable_cache(async (uid: string) => {
return supabase.from('documents').select()
}, ['docs'])That would be a strong fifth test — red on both branches, since no policy file Minor, on the env-var point: Happy to open a PR with the view case and the cache case if you want them in the |
Uh oh!
There was an error while loading. Please reload this page.
If you ship Next.js on Supabase, the authorization boundary lives in Postgres rather than in your route handlers, and it fails in a way the App Router will never show you: the page renders, the query succeeds, and it returns rows that belong to somebody else.
I built a minimal reproduction of the most common version of that, because arguing about it in the abstract goes nowhere.
The fixture — MIT, no Docker, no cloud project, no credentials. It runs Postgres in PGlite, so it is
npm ciand about two seconds:https://github.com/cekuu35/supabase-rls-leak-demo
npm ci && npm run test:ciTwo branches, byte-for-byte identical test files, differing only by one policy file.
brokengives you 4 failed / 1 passed,fixedgives you 5 passed. The failure prints the actual leaked row, so it is obvious what happened rather than being an assertion count.Three Next.js-specific things that come up around it
process.env.SUPABASE_SERVICE_ROLE_KEYis not inlined into your client bundle — the docs are explicit that non-NEXT_PUBLIC_variables stay server-side. What does reach the browser is a value you hand across the boundary yourself, e.g. a Server Component passing it as a prop to a'use client'component, because that goes into the RSC payload. A grep of.next/staticwill not find that one; you have to look at the rendered response.RLS policies are a performance surface as well as a security one.
auth.uid() = user_idis re-evaluated per row;(select auth.uid()) = user_idbecomes an InitPlan evaluated once. Supabase's own benchmarks put the worst case at 178,000ms → 12ms, and the dashboard'sAdvisors → Performancetab lists every instance for you underauth_rls_initplan.A policy that exists is not a policy that runs. If RLS was never enabled on the table, the policy rows still sit in
pg_policiesand nothing is enforced — which reads as configured in every UI you look at.Disclosure, since two of the links are mine to sell: the fixture above and a 10-check pre-ship PDF are free with no email wall. I also sell a $29 kit that packages the same audit as seven commented SQL files you run against your own catalogs, and I do the audit as a service. Run the free Supabase linter first — if it comes back clean and the query in the repo agrees, you are done and you have spent nothing.
Happy to look at a specific policy if anyone wants to paste one.
All reactions