IS SUPABASE SAFE? SUPABASE SECURITY REVIEW 2026
Supabase is production-grade infrastructure. The risk is treating the anon key as private and shipping tables without RLS — the default failure mode in AI-generated apps.
CHECK YOUR SUPABASE APP NOW
Enter your deployed URL — we exercise the anon key against your tables and report the rows it should not be able to reach.
RLS is Non-Negotiable
Supabase exposes your PostgreSQL database directly to clients via the anon key. Without RLS policies, anyone with your project URL can read, modify, or delete all data in unprotected tables. The anon key is not a secret — it ships in the JavaScript bundle of every Supabase app on purpose. That design choice is fine when RLS is enforced. When RLS is not enforced, every table is a public API.
The pattern we see most often: AI generators scaffold a Supabase project, create five tables, and write the frontend that reads from them. They never run ALTER TABLE x ENABLE ROW LEVEL SECURITY. The app works in dev because the dev project has one user. It works in prod for one user. It works for two users — until user B types user A’s UUID into the URL bar.
Common Security Issues
Missing RLS Policies
Tables without RLS enabled are fully accessible to anyone with the anon key, leading to complete data exposure. Check with:
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public' AND rowsecurity = false;
Anything in that result is publicly readable through the Supabase REST or GraphQL endpoint.
Service Role Key Leaks
The service_role key bypasses RLS. Exposing it in client code grants full database access to attackers. Common leak paths: pasted into a .env.local that ships in the Next.js bundle (NEXT_PUBLIC_* variables), committed to a public repo “just for the demo”, logged at server boot, or returned in an error response from an Edge Function.
Rotate immediately if you suspect a leak — and after rotation, audit pg_stat_statements for queries that wouldn’t have come from your app.
Flawed RLS Policies
RLS policies with logical errors create unintended access paths. The most common mistake is the “permissive policy that allows everything”:
-- BAD: this is the same as no RLS
CREATE POLICY "users can read" ON profiles
FOR SELECT USING (true);
-- GOOD: scope to the authenticated user
CREATE POLICY "users read own profile" ON profiles
FOR SELECT USING (auth.uid() = user_id);
Equally dangerous is the policy that uses auth.uid() only on SELECT and forgets UPDATE/DELETE/INSERT. Each command needs its own policy unless you use FOR ALL.
Storage Bucket Misconfigurations
Supabase Storage also requires RLS. Public buckets may expose sensitive files. The standard mistake is making a bucket public so the CDN works, then storing per-tenant files in it with predictable paths like /uploads/{user_id}/{file}.pdf. Anyone who guesses a UUID gets the file.
For private buckets, write a Storage policy:
CREATE POLICY "users read own files"
ON storage.objects FOR SELECT
USING (auth.uid()::text = (storage.foldername(name))[1]);
JWT Secret Reuse Across Projects
Supabase signs JWTs with a per-project secret. If you copy the secret across staging and prod (or share it across two unrelated apps), a token issued in one environment is valid in the other. Always treat the JWT secret as a per-environment value.
Unprotected Database Functions
SECURITY DEFINER functions run with the privileges of the function owner — usually a superuser. A function that takes a user_id parameter and reads from any table without checking the caller is a complete RLS bypass. Audit every SECURITY DEFINER function and confirm it validates auth.uid() against its inputs.
Security Assessment
Strengths
-
- PostgreSQL with enterprise-grade security
-
- Row Level Security (RLS) for fine-grained access
-
- Built-in authentication with JWT tokens
-
- Open source - security auditable
-
- SOC 2 Type II compliance
-
- Encryption at rest and in transit by default
-
- Per-project JWT signing keys
Concerns
-
- RLS policies often missing or misconfigured
-
- Default settings may expose data
-
- Anon key in client code - RLS is essential
-
- Service role key leaks grant full access
-
- Complex RLS syntax leads to security gaps
-
- SECURITY DEFINER functions can bypass RLS silently
-
- Storage buckets need their own policies, easy to forget
Supabase vs Firebase: a quick model comparison
Both platforms put a database directly behind a public key and rely on declarative rules to keep data scoped. The differences:
- Rule language. Supabase uses SQL via
CREATE POLICY. Firebase uses its own Security Rules DSL. SQL is more powerful and composable; the DSL is more compact for simple per-document rules. - Default after enable. RLS enabled with no policy = deny all. Firebase rules with no
matchblock = deny all. Both fail safely if you stop halfway. Both fail unsafely if you write a permissive rule to “make it work”. - Auth integration. Both ship first-party auth that injects a user ID into rules. Supabase exposes
auth.uid(); Firebase exposesrequest.auth.uid. - Server escape hatch. Supabase has
service_role. Firebase has the Admin SDK. Both bypass all rules. Both are the source of most catastrophic leaks.
Edge Functions, Realtime, and RPC
Edge Functions
Deno Edge Functions often run with access to service_role. That is fine for webhooks and admin jobs; fatal if the function is a thin proxy:
// BAD: caller chooses the user id, service role fetches anything
const { userId } = await req.json();
const { data } = await admin.from("profiles").select("*").eq("id", userId);
Prefer verifying the caller’s JWT and using an anon client with the caller’s Authorization header so RLS still applies. If you must use service_role, hard-code the authorization logic and never take raw table/path names from the client.
Disable --no-verify-jwt except for genuine public webhooks, and verify webhook signatures (Stripe, GitHub) inside the function.
Realtime
postgres_changes subscriptions evaluate RLS for the subscribing user — but only if RLS is correct. A channel without a filter on a world-readable table broadcasts everyone’s inserts. Scope subscriptions:
supabase.channel("mine")
.on("postgres_changes", {
event: "*",
schema: "public",
table: "messages",
filter: `user_id=eq.${user.id}`,
}, handler)
.subscribe();
RPC / SECURITY DEFINER
PostgREST exposes functions under /rest/v1/rpc. A SECURITY DEFINER function that takes user_id and returns rows without checking auth.uid() is a full RLS bypass. Audit:
select n.nspname, p.proname, p.prosecdef
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where p.prosecdef = true and n.nspname = 'public';
How attackers abuse a misconfigured project
- Extract project URL + anon key from your JS bundle (or mobile app).
- Fetch OpenAPI at
/rest/v1/→ full table list. GET /rest/v1/<table>?select=*withAuthorization: Bearer <anon>.- If writes open,
POSTforged rows (includingis_admin: trueif your app trusts that column). - List Storage buckets; download public objects; brute paths on private buckets with weak policies.
- Hit
/auth/v1/tokenwith combo lists if password policy is weak.
None of this requires SQL injection. It is the intended API used without the intended policies. The Supabase RLS Checker walks steps 1–3 automatically.
How to verify RLS
-- Tables still public
select tablename from pg_tables
where schemaname = 'public' and rowsecurity = false;
-- Policies present
select tablename, policyname, cmd, qual, with_check
from pg_policies where schemaname = 'public';
# Anon should get empty/error for private tables
curl -s "$URL/rest/v1/profiles?select=*&limit=1" \
-H "apikey: $ANON" -H "Authorization: Bearer $ANON"
Then repeat with two real user JWTs and confirm cross-user reads fail. Add pgTAP or dashboard policy tests in CI for every new table.
Common mistakes in AI-generated Supabase apps
- Creating tables in Lovable/Bolt prompts without enabling RLS in the same change.
- “Fixing” locked tables with
USING (true). - Putting
service_roleinNEXT_PUBLIC_*/VITE_*/ mobile apps. - Storage bucket set to public for convenience.
- Only
SELECTpolicies;INSERT/UPDATE/DELETEwide open (or the reverse). - Trusting
role/is_admincolumns writable by clients. - Edge Functions with
--no-verify-jwtleft from a tutorial. - Shared JWT secret across staging and production.
Multi-tenant and org-scoped policies
Most production SaaS apps outgrow single-user ownership. A workspace model usually looks like: orgs, org_members (user_id, org_id, role), and business tables with org_id. AI generators almost never produce this correctly on the first pass. They either leave every table scoped only to auth.uid() = user_id (breaking team features) or they put org_id in the client and trust it (breaking isolation).
A workable pattern:
-- Membership helper (prefer SECURITY INVOKER; if DEFINER, lock it down)
create or replace function is_org_member(check_org uuid)
returns boolean
language sql
stable
as $$
select exists (
select 1 from org_members
where org_id = check_org
and user_id = auth.uid()
);
$$;
create policy "members read invoices"
on invoices for select
using (is_org_member(org_id));
create policy "members insert invoices"
on invoices for insert
with check (is_org_member(org_id));
Hard rules for multi-tenant Supabase:
- Never accept
org_idfrom the client without verifying membership server-side (or in the policy). - Role elevation belongs in a server path with service_role — not in a client-writable
rolecolumn onorg_members. - Test with three accounts: owner of org A, member of org A, member of org B. Cross-org SELECT and UPDATE must fail closed.
- Indexes on
(org_id, ...)matter for policy performance; slow RLS policies get “fixed” by developers who disable RLS under load.
Custom JWT claims (app_metadata.org_ids) can speed policies, but claims go stale when membership changes. Prefer live joins on org_members unless you have a proven claim-refresh path.
Views, foreign tables, and the silent bypass
Postgres views are a frequent RLS footgun. A view defined by a privileged owner can expose underlying rows unless you understand security invoker vs definer behavior and whether RLS applies to the base tables under the view’s execution context. In modern Postgres, prefer security_invoker = true on views that should respect the caller’s RLS.
Checklist:
-- List views in public
select table_name from information_schema.views
where table_schema = 'public';
For each view: who owns it, does it expose columns you would not grant on the base table, and can the anon role SELECT it? Same discipline for foreign tables and materialized views: if the client can hit them through PostgREST, they need an authorization story.
Auth configuration that AI leaves wide open
RLS only helps if identity is trustworthy. Weak Auth settings turn every policy into theater.
| Setting | Safe default | Why |
|---|---|---|
| Email confirmation | On for production | Stops disposable-account spam and some takeover paths |
| Secure email change | On | Prevents email swap without re-verify |
| Password strength / leaked password | On | Credential stuffing is free for attackers |
| JWT expiry | Short access, controlled refresh | Limits stolen-token lifetime |
| Redirect URLs | Exact production origins only | Open redirects → token theft |
| Site URL | Production domain | Magic links and OAuth land correctly |
| MFA for privileged users | Required for admin roles | Softens session theft |
OAuth providers need an allowlisted redirect set that matches your real domains — not localhost left from a tutorial. Phone OTP without rate limits becomes an SMS-cost weapon. Disable unused providers so you are not maintaining attack surface you never productized.
Incident response when the anon key already dumped data
Assume the worst if RLS was ever off on a table that held PII:
- Contain — enable RLS immediately; add deny-by-default then owner policies; rotate service_role if it was ever client-exposed.
- Scope — check PostgREST logs / API logs for bulk
selectvolume, unusual IPs, and Storage downloads. Note table names and date ranges. - Rotate — all JWTs (sign-out all users if needed), OAuth client secrets if mixed in the same incident, third-party keys found in the same bundle.
- Notify — follow your DPA and local breach rules (e.g. GDPR 72-hour, Australian NDB). Document what was accessible, not only what you think was accessed.
- Prevent — add CI RLS tests and a live Supabase RLS Checker gate so the next prompt cannot re-open a table.
If service_role leaked, treat it as full database compromise: rotate, audit auth.users and privileged tables for unauthorized inserts, and review Edge Function logs for admin-shaped traffic.
Testing policies like production code
Hope is not a policy test. Three layers:
SQL / dashboard. Create two test users. As user A, insert rows. As user B (or anon), attempt SELECT/UPDATE/DELETE. Expect empty sets or permission errors, never another user’s rows.
pgTAP in CI. Encode each policy as a test that fails the pipeline when a new migration ships a naked table.
Live HTTP. The curl the attacker uses:
# Anon
curl -s "$URL/rest/v1/invoices?select=*&limit=5" \
-H "apikey: $ANON" -H "Authorization: Bearer $ANON"
# User B JWT trying user A's id filter
curl -s "$URL/rest/v1/invoices?select=*&user_id=eq.$USER_A" \
-H "apikey: $ANON" -H "Authorization: Bearer $USER_B_JWT"
Automate both against staging and production (read-only probes where possible). AI-assisted features that “add a billing table” are the #1 reason green policies go red two sprints later.
Observability and least privilege beyond RLS
Enable the Supabase security advisor / linter findings and fix them like compiler errors. Ship API and Auth logs to a long-retention sink (not only the dashboard stream). Alert on:
- Sudden volume of
401/403from a single IP (probing) - Spike of successful
selectrow counts on sensitive tables - New Edge Function deploys without review
service_roleusage from unexpected callers
Database roles: avoid using a superuser for app traffic. Keep authenticator / API roles least-privileged. Restrict which schemas PostgREST exposes (public only if intentional; never expose internal helper schemas).
FORCE ROW LEVEL SECURITY and table owners
Table owners and superusers can bypass RLS unless you FORCE ROW LEVEL SECURITY. In AI-generated projects the migration role often owns every table — meaning a compromised server path running as owner silently skips policies. For multi-tenant data, FORCE RLS on sensitive tables so even privileged SQL sessions must satisfy policies unless you deliberately use a bypass role for migrations.
Pre-launch checklist
rowsecurity = trueon everypublictable (and FORCE RLS where appropriate).- Per-command policies scoped with
auth.uid()(or org claims you actually set). - No
USING (true)unless the table is intentionally world-readable. - service_role only on server; rotated if ever leaked; grepped from bundles.
- Storage policies + private buckets for sensitive files.
- Auth: email confirm, leaked-password protection, tight redirect URLs.
- Edge Functions JWT verification on; webhooks signature-checked.
- Realtime filters + RLS verified.
- SECURITY DEFINER RPCs audited.
- Views and RPCs included in the authorization review, not only base tables.
- Two-user + anon HTTP probes in CI or pre-release.
- Live scan with VibeEval + RLS Checker + Token Leak Checker.
Policy testing that survives the next Lovable prompt
RLS that only exists in the dashboard will drift. Prefer policies in migrations or a supabase/migrations SQL file committed to git, applied in CI to a branch database, then promoted. After any AI session that creates tables, run:
-- tables still missing RLS
select c.relname
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind = 'r'
and not c.relrowsecurity;
And a dual-role smoke test: create two users via Auth, insert a row as A, select as B with the anon/authenticated key, expect zero rows.
with check vs using — the update footgun
using decides which existing rows you can touch. with check decides whether the new row image is allowed. Generators often write:
create policy "update_own" on notes for update
using (auth.uid() = user_id);
-- missing with check — user can set user_id to someone else
Always pair update/insert with with check (auth.uid() = user_id) (or your tenant predicate).
Views, RPCs, and security definer
A security definer function that returns select * from private_table reintroduces a full dump unless it re-checks auth.uid(). AI tools love helper RPCs for “complicated queries.” Audit every function in public for definer rights and missing auth checks. Prefer security invoker (Postgres 15+) so RLS of the caller still applies.
Storage + image CDN patterns
Public buckets for avatars are fine if objects are non-sensitive. Product exports, KYC images, and chat attachments need private buckets and short-lived signed URLs. Do not put signed URLs with long TTLs in public HTML. Re-issue on demand from an authenticated Edge Function.
Connection pooling and service role on the server
Server routes (Next.js route handlers, Edge Functions) should use the service role only when they enforce authz in code and never proxy arbitrary filters from the client. Prefer the user JWT + anon/authenticated key so RLS remains the last line. If you must use service role for admin jobs, isolate those functions, lock down network, and log every query shape.
Org / multi-tenant models
Two workable patterns:
org_idon every row + membership table + policies likeorg_id in (select org_id from members where user_id = auth.uid())- Schema-per-tenant (heavier ops)
AI generators almost always pick (1) and forget the membership join. Test a user who is not a member of org X against every list endpoint.
Local-first checklist after clone
- Never commit
.envwith real keys. - Use
supabase startor a dedicated free project for local — not production. - Seed data without real PII.
- Enable RLS on seed tables before the first UI demo.
Grants, PostgREST, and the “RLS on but still open” mystery
RLS is not the only gate. Postgres still applies GRANT privileges. A table can have RLS enabled with good policies and still surprise you if:
- The
anon/authenticatedroles have unexpected grants on sequences or related tables used in policies. - A policy subquery reads a helper table that is world-readable, leaking existence or data through clever filters.
- Column-level privileges are ignored because the client uses
select=*and your policy allows the row.
PostgREST also exposes only schemas you configure. Narrow exposure to public (or a dedicated api schema) and keep staging junk tables out of the API schema entirely. AI migrations love dumping everything into public.
When debugging “why can anon read this,” check in order: RLS enabled? Policies present? Policy expression actually false for anon? GRANT on table? View security? Service role accidentally used?
Local development vs production parity
Developers often use the service role in local scripts and the Supabase dashboard SQL editor (bypass). That trains bad habits: “the query works” without proving it works as the user. Mandate:
- App code paths use anon + user JWT locally.
- A
make test-rlstarget that fails CI on open tables. - Seed scripts that create two users and assert isolation.
Production-only RLS testing is how open tables survive for months.
The Verdict
Supabase is safe as a platform with PostgreSQL’s battle-tested security. The critical factor is proper RLS configuration. Enable RLS on every table, write and test policies thoroughly, and never expose the service_role key in client code. With proper configuration, Supabase provides excellent security.
The four checks before production:
- Every table in the
publicschema hasrowsecurity = true. - Every table has at least one policy per command (
SELECT,INSERT,UPDATE,DELETE) that referencesauth.uid(). - The service_role key exists only in trusted server environments — grep your repo and your client bundle for it.
- Storage buckets are private unless they are genuinely meant for the public CDN, and private buckets have policies that scope by user.
If those four hold, and Edge Functions do not reintroduce god-mode proxies, Supabase is an excellent foundation for production SaaS — including AI-generated frontends that talk to the database directly.
Related Resources
How to Secure Supabase
Step-by-step security guide covering RLS rollout, policy testing patterns, and Storage bucket lockdown.
Supabase Security Checklist
Interactive security checklist with the SQL queries to confirm each item.
Supabase RLS Guide
Deep dive into writing and testing Row Level Security policies that actually check the caller.
Supabase RLS Checker
Run an automated scan against your live Supabase project that probes every table for RLS bypass and reports any rows the anon key can reach.
Token Leak Checker
Find the service_role key (and other secrets) that may have shipped to your browser bundle.
Dashboard clicks that undo migrations
Granting a table to anon in the UI or disabling RLS “temporarily” bypasses git. Prefer SQL migrations only; restrict who has dashboard owner rights; audit ddl_command_end when available.
Scan Your Supabase App
Let VibeEval check your Supabase application for RLS misconfigurations and vulnerabilities. The scanner walks every public route, exercises the anon key against your tables, and reports the rows it should not have been able to read.
COMMON QUESTIONS
TEST RLS WITH THE ANON KEY
We use your public anon key the way an attacker would and list every table row it should not reach. 14-day trial, no card.
14-day free trial · No credit card · Cancel anytime