Supabase security checklist: RLS, auth, keys and storage for Next.js and Lovable apps
A free, copyable checklist for apps built on Supabase, whether hand-written in Next.js or generated by Lovable, Bolt, v0 or Cursor. Each item says what to look for, why it matters, and the fix. It came out of a static code review of three open-source Supabase + Next.js starters.
1. The 10-minute A/B test scanners can't do
Scanners (Supabase Security Advisor, Lovable's scans, open-source linters) check policy shapes and known misconfigurations. They don't know which rows belong to which customer. This test does.
- Create two normal accounts in your app: A and B. Create some private data as A.
- Signed in as B, call the API directly (browser console or a script using the publishable key and B's session): read A's rows by id, list the table with your app's filters removed, update A's row, delete A's row.
- As B, insert a row with A's
user_id(ororg_id/workspace_id), and try to update your own row to set the owner column to A. - Check the row count, not the status code. When RLS filters out an UPDATE or DELETE, the request still succeeds and simply affects zero rows. "No error" does not mean "blocked", and it does not mean "allowed".
- Repeat everything signed out (the
anonrole). - Do the same for storage paths, RPC functions and your own server routes. Re-run it after every migration or AI edit that touches policies; Supabase supports SQL policy tests under
supabase/tests/run withsupabase test db.
// as user B, with the normal browser client
const { data, error } = await supabase
.from('invoices').update({ amount: 0 })
.eq('id', A_INVOICE_ID).select();
// expect: error == null AND data.length === 0 (blocked)
// data.length === 1 means B just changed A's row
Want the read, update and storage parts scripted? supabase-rls-ab-test is a free, MIT-licensed Python script (no dependencies) that signs in as A and B with your publishable key and reports any row B can read or update. Read-only unless you pass --write, and that write is a no-op. Run it on staging.
2. Row Level Security and grants
- RLS is enabled on every table in an exposed schema (
publicby default). Tables created in the Dashboard Table Editor get RLS on by default; tables created with raw SQL or migrations needalter table ... enable row level security. A table that is granted toanon/authenticatedwith RLS off is readable and writable by anyone with your publishable key. Critical if it holds user data - Grants are deliberate. Postgres checks grants first, then RLS. On existing projects, new tables in
publichave historically been auto-granted toanon,authenticatedandservice_role. Supabase is turning that off: new projects since May 30, 2026, and existing projects for newly created tables from October 30, 2026. After that, a new table without a grant fails with42501 permission denied. Don't "fix" that withgrant ... on all tables in schema public to anon; grant only what each role needs, in the same migration that creates the table. - No
using (true)/with check (true)on private tables. Public catalog tables (products, prices) are fine;profiles,orders,messagesare not. - INSERT and UPDATE policies pin ownership in
with check. INSERT policies only takewith check, so make it(select auth.uid()) = user_id, nottrue. For UPDATE, Postgres reusesusingas the check whenwith checkis omitted; that's safe only if the expression pins the owner column. A permissivewith checklets a user hand a row to someone else, or create rows owned by another user. - Ownership comes from
auth.uid()or verified membership, never from a request-supplied id. The owner column isnot nulland referencesauth.users. Don't base policies onuser_metadata, which users can edit themselves (Security Advisor lint 0015). - Views and
SECURITY DEFINERfunctions are reviewed separately. Both can bypass RLS. Pinsearch_pathon definer functions (lint 0011) and revokeexecutefromanon/publicon anything that shouldn't be callable from the browser. - Security Advisor is clean, or every remaining finding is explained. Run it in Studio (Database › Security Advisor) or with
supabase db advisors. Useful, but it can't replace the A/B test.
3. Keys and secrets
- Only the publishable key (
sb_publishable_...) or legacyanonkey reaches the browser. These are public by design; RLS is what protects data. - The secret key (
sb_secret_...) and legacyservice_rolekey never reach the browser. They bypass RLS entirely. Search your built JS (.next/static,dist/) forservice_role,sb_secret_and longeyJ...strings. Critical - Nothing secret behind
NEXT_PUBLIC_/VITE_. Those prefixes are bundled into client code. - Server files that create an admin client never get imported into a client component (no
'use client'in the import chain; consider theserver-onlypackage). - No real keys in committed
.envfiles or git history. If a secret leaked, fix the cause, then rotate it in Settings › API Keys. - Supabase is retiring legacy
anon/service_rolekeys by end of 2026. Plan the move to publishable/secret keys.
4. Server auth: getClaims / getUser vs getSession
- Server code decides who the user is with
getClaims()orgetUser(), never withgetSession(). From Supabase's Next.js SSR guide: "Never trustsupabase.auth.getSession()inside server code such as Proxy. It reads the session out of the cookie without revalidating it."getClaims()verifies the JWT signature (locally on projects with asymmetric signing keys);getUser()asks the Auth server for the current user record. In a local test, an unsigned, forged cookie came back fromgetSession()on the server as a fully populated user. Medium to high, depending on what that user id unlocks - Watch where the user id goes. The dangerous pattern is
session.user.idfromgetSession()flowing into a service-role query, a Stripe customer lookup or a billing portal link. RLS doesn't help there because the secret key bypasses it. - The Next.js middleware (
proxy.tsin Next.js 16,middleware.tsbefore) refreshes the session and protected routes fail closed. Look for guards that are commented out, or route lists that miss new pages. Middleware is not the only gate: every Server Action and Route Handler that touches private data checks the user itself. - Session responses aren't cached. If you use ISR or a CDN, a cached
Set-Cookiecan sign one visitor in as another. Apply the cache headers@supabase/ssrpasses tosetAll. - Sign-up abuse: email confirmation on, a sensible minimum password length, CAPTCHA (Turnstile or hCaptcha) on public sign-up, and anonymous sign-ins off unless you use them (lint 0012).
5. Edge Functions and API routes
- Every function or route that uses the secret key checks the caller first (a verified user via
getClaims()/getUser(), a shared secret, or a webhook signature). A service-role endpoint with no caller check is an open admin API. High - Don't rely on
verify_jwtalone. Supabase notes that with the new publishable/secret keys, passing theverify_jwtcheck doesn't authenticate the caller; authorize in code. Ifverify_jwt = false(common for webhooks), the function must do its own check. - Webhooks verify signatures on the raw body (
stripe.webhooks.constructEvent, svix, etc.). - RPC / server functions taking a
user_idparameter ignore it and use the authenticated user, or check that it matches.
6. Storage buckets
- Public buckets are public for reads. Supabase docs: a public bucket bypasses access control for retrieving and serving files, so anyone with the URL can download them. Uploading, deleting, moving and copying are still controlled by policies. Fine for avatars and blog images; wrong for invoices, IDs or private uploads. Buckets are private by default.
- Private-bucket policies on
storage.objectsscope to the user, typicallybucket_id = 'docs' and (storage.foldername(name))[1] = (select auth.uid())::text. A policy that only checksbucket_idlets every signed-in user read every file. High - Test list, download, upload, overwrite and delete separately as user B against A's folder. Upsert needs select and update permissions too.
- Signed URLs have short expiries, and upload paths are built server-side or sanitized, not taken verbatim from the client.
7. Redirects and OAuth
- Post-login redirects only go to relative, same-origin paths. A raw
?next=or?redirect=used verbatim is an open redirect. - Redirect URLs aren't built from
Host/X-Forwarded-Hostunless your platform pins those headers. - The Auth redirect allow-list is tight in production (no wildcard localhost or preview domains you don't control).
8. Referral attribution (the money bug)
?ref=/ UTM survives the OAuth round trip.signInWithOAuthsends the user to Google or GitHub and back to a fixedredirectTo; the query string from the landing page is gone. Referred signups then look organic and partners aren't credited. Fix: store the ref in a first-party cookie (or an allow-listedredirectToparameter) before redirecting, then read it server-side in the callback.- Attribution is written server-side, once, and can't be forged by the client. If the browser writes
referred_bydirectly, users can self-refer or credit anyone. Write it in the callback (or in aSECURITY DEFINERfunction with a pinnedsearch_paththat usesauth.uid()and only sets the value when it's empty). Check that the referrer exists. - Self-referral is blocked and rewards are idempotent per referred user (no double credit on retries).
9. Lovable and other AI-built apps
Lovable apps store data in Supabase, so everything above applies. Lovable runs a free Quick scan on every publish and offers an on-demand Deep scan (More › Security). Run both. Lovable's own docs say these tools "do not replace a thorough security review". In practice the gaps are the ones a scanner can't infer:
- Which rows belong to which customer or team (run the A/B test above).
- Lead or contact forms: the table should allow
insertforanonand nothing else. Watch for.insert(...).select(): returning the new row needs a select policy, and the usual "fix" opens select to everyone. Drop the.select()instead. - Edge Functions the AI wrote with the secret key and no caller check.
- Policies that changed after the last scan. Re-test after every prompt that touches the database.
10. What showed up in 3 open-source starters
We ran this checklist against three public Supabase + Next.js starters (code only, pinned commits, no live systems contacted): vercel/nextjs-subscription-payments, KolbySisk/next-supabase-stripe-starter and Razikus/supabase-nextjs-template.
- Server authorization built on
getSession()in one starter, feeding a service-role Stripe customer lookup and billing-portal route, with the middleware route guard commented out. Exploiting it still needs the victim's user id, so it's high, not critical. ?ref=/ UTM lost on OAuth sign-in in all three (constantredirectTo, nothing read in the callback).- Smaller items: full DML (including
truncate) granted toanonon a table protected only by RLS;SECURITY DEFINERtrigger functions without a pinnedsearch_path; a real project.envcommitted in one template. - Verified fine: public
products/pricescatalog policies, Stripe webhook signature checks, upload paths scoped to the user's folder.
FAQ
Is enabling RLS enough to secure a Supabase app?
No. Enabling RLS closes a table until a policy opens it. The risk then lives in the policies themselves (for example using (true) on a private table, or an UPDATE policy that lets a user reassign the owner column) and in places RLS doesn't reach: server code using the secret or service_role key, storage bucket settings, and direct Postgres connections.
Should I use getSession, getUser or getClaims on the server?
Use getClaims() to verify who the user is; it verifies the JWT signature. Use getUser() when you need a fresh user record from the Auth server. Don't use the user object from getSession() for authorization in server code: Supabase's docs say it reads the session out of the cookie without revalidating it, so a forged cookie can produce a forged user id.
Is the Supabase anon or publishable key a secret?
No. The publishable key and the legacy anon key are meant to ship in browser and mobile code; RLS decides what they can reach. The secret key and legacy service_role key bypass RLS and must only live in server-side code you control.
Are files in a public Supabase storage bucket safe?
Anyone with the URL can download files from a public bucket; reads bypass policies. Uploads, deletes, moves and copies are still controlled by policies on storage.objects. Keep private files in private buckets and scope policies to the user.
Does Lovable's security scan replace a manual check?
Run it; it's free. But Lovable's docs say its scans don't replace a thorough security review, and no scanner knows which rows belong to which customer. Do the two-account test.
How do I test that one user can't see another user's data?
Create accounts A and B. As B, try to read, update and delete A's rows through the API, and insert a row with A's user_id. A blocked update or delete returns success with zero rows, so check the row count, not the status code. Repeat signed out.
Want it done for you? Fixed-fee audit: $149
I run the A/B test above plus a code review against this checklist, then send a written report with file-and-line findings, severity and fixes.
You only pay if I can demonstrate at least one of these:
- account B reading or changing account A's data,
- a forged session getting through one of your server routes, or
- signups losing their
ref/ referral attribution.
Doc reminders and hygiene notes don't count. If none of the three turns up, you get the report and owe nothing.
To start, email: your app URL (staging is best), repo access or a code export, your stack (Next.js, Lovable, Bolt...), and confirmation that you own the app and we may test it with two test accounts. I only test systems the owner authorizes. No payment is taken on this site.
Email an audit request