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 match block = 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 exposes request.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

  1. Extract project URL + anon key from your JS bundle (or mobile app).
  2. Fetch OpenAPI at /rest/v1/ → full table list.
  3. GET /rest/v1/<table>?select=* with Authorization: Bearer <anon>.
  4. If writes open, POST forged rows (including is_admin: true if your app trusts that column).
  5. List Storage buckets; download public objects; brute paths on private buckets with weak policies.
  6. Hit /auth/v1/token with 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_role in NEXT_PUBLIC_* / VITE_* / mobile apps.
  • Storage bucket set to public for convenience.
  • Only SELECT policies; INSERT/UPDATE/DELETE wide open (or the reverse).
  • Trusting role / is_admin columns writable by clients.
  • Edge Functions with --no-verify-jwt left 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_id from 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 role column on org_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:

  1. Contain — enable RLS immediately; add deny-by-default then owner policies; rotate service_role if it was ever client-exposed.
  2. Scope — check PostgREST logs / API logs for bulk select volume, unusual IPs, and Storage downloads. Note table names and date ranges.
  3. 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.
  4. 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.
  5. 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/403 from a single IP (probing)
  • Spike of successful select row counts on sensitive tables
  • New Edge Function deploys without review
  • service_role usage 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

  1. rowsecurity = true on every public table (and FORCE RLS where appropriate).
  2. Per-command policies scoped with auth.uid() (or org claims you actually set).
  3. No USING (true) unless the table is intentionally world-readable.
  4. service_role only on server; rotated if ever leaked; grepped from bundles.
  5. Storage policies + private buckets for sensitive files.
  6. Auth: email confirm, leaked-password protection, tight redirect URLs.
  7. Edge Functions JWT verification on; webhooks signature-checked.
  8. Realtime filters + RLS verified.
  9. SECURITY DEFINER RPCs audited.
  10. Views and RPCs included in the authorization review, not only base tables.
  11. Two-user + anon HTTP probes in CI or pre-release.
  12. 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:

  1. org_id on every row + membership table + policies like org_id in (select org_id from members where user_id = auth.uid())
  2. 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

  1. Never commit .env with real keys.
  2. Use supabase start or a dedicated free project for local — not production.
  3. Seed data without real PII.
  4. 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 / authenticated roles 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-rls target 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:

  1. Every table in the public schema has rowsecurity = true.
  2. Every table has at least one policy per command (SELECT, INSERT, UPDATE, DELETE) that references auth.uid().
  3. The service_role key exists only in trusted server environments — grep your repo and your client bundle for it.
  4. 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.

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

01
Is Supabase safe to use in production?
Yes, when you treat the anon key as public and protect every table with Row Level Security. The platform itself is SOC 2 Type II compliant and runs on managed PostgreSQL. The risk is not in the platform — it is in tables that ship with RLS disabled or with policies that read like access controls but actually allow anything.
Q&A
02
Why is the Supabase anon key not really anonymous?
The anon key is a JWT signed by your project that gets embedded in your browser bundle. Anyone visiting your site has it. It is the equivalent of publishing a database connection string — except the only thing standing between that key and your data is RLS. If RLS is off, the anon key reads everything.
Q&A
03
Does enabling RLS on a table block all access?
Yes. Enabling RLS without writing any policy denies all reads and writes from the anon role. This is the safe default. The mistake AI generators make is enabling RLS and then immediately writing a permissive policy like `USING (true)` to make the app work, which is the same as not having RLS at all.
Q&A
04
What is the difference between anon, authenticated, and service_role keys?
anon is the public key in the browser. authenticated is the same key after a user signs in — RLS policies can reference `auth.uid()` to scope rows. service_role is a god-mode key that bypasses RLS entirely. service_role belongs only on a trusted server, never in client code or a public repo.
Q&A
05
Are Supabase Storage buckets covered by RLS?
Yes — Storage uses the same RLS engine. A public bucket means anyone can read every object. A bucket with RLS but no policies denies reads. Most leaks happen with buckets marked public for ease of CDN access without considering that file paths often encode tenant IDs.
Q&A
06
Can Supabase Edge Functions leak the service_role key?
Edge Functions run server-side and have access to environment secrets, including service_role. The leak happens when developers log the env at startup, return error messages that include connection details, or accidentally expose the function source map. Treat any Edge Function with service_role as a privileged service.
Q&A
07
How does Supabase compare to Firebase for security?
Both expose data directly to clients and rely on declarative rules. Supabase uses PostgreSQL RLS, which is strict-deny by default once enabled. Firebase Security Rules are deny-by-default but use a custom rule language. RLS gives you access to the full Postgres role and policy system; Firebase gives you tighter integration with Auth. Both fail the same way: rules that nominally allow access without actually checking the user.
Q&A
08
Is the Supabase anon key a vulnerability by itself?
No. Shipping the anon key is the documented client model. The vulnerability is missing or incorrect RLS (or a leaked service_role key). Treat the anon key like a public API identifier and put real access control in policies.
Q&A
09
How often should I re-check RLS?
Every time you add a table, view, RPC, or Storage bucket — and after any AI-assisted feature prompt. New tables default to RLS off. Re-run the Supabase RLS Checker on the production URL before each launch.
Q&A

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

SCAN MY SUPABASE APP