Skip to main content
Application & APICritical

Missing Supabase Row Level Security (RLS)

Missing Row Level Security (RLS) means a Supabase project's public anon key, which is meant to be shipped in browser code, can read or write every row through the auto-generated REST and GraphQL API because no row-level authorization policy gates the request. RLS, not the anon key, is the security boundary, and when it is disabled the entire database is effectively public.

Supabase hands every frontend a public anonymous (anon) key and a project URL so the browser can talk to the database directly through an auto-generated PostgREST API. That design is safe only when Postgres Row Level Security (RLS) is enforcing who can touch which row. A growing class of AI-generated, 'vibe-coded' applications ships with the anon key exposed in client JavaScript but RLS never enabled, which turns a convenience feature into a wide-open data tap that anyone with a browser and the network tab can drain.

Reviewed by Aakash Harish

Security Research Contributor, Legba

Reviewed 2026-05-28 · Updated 2026-05-28

What it is

Supabase exposes your Postgres database to the internet through PostgREST (a REST API at /rest/v1/) and an optional GraphQL endpoint, authenticated by JSON Web Tokens. The publishable anon key is a JWT carrying the 'anon' role and is intentionally embedded in client-side code. Authorization is not enforced by the key itself; it is enforced by Postgres Row Level Security policies, which attach an implicit WHERE clause to every query so a role only sees or modifies rows it is permitted to. Missing RLS is the condition where a table reachable through the API has RLS turned off (or enabled with no restrictive policies), so the anon role can SELECT, INSERT, UPDATE, and DELETE every row. Critically, tables created via raw SQL do not have RLS enabled by default, so a table that looks private is actually world-accessible.

When RLS is missing, the question is not whether an attacker can reach your data but how quickly they will. The anon key is published in your bundle by design, so there is no credential to steal and no brute force required: an attacker pages through /rest/v1/<table> and exfiltrates user records, password reset tokens, payment metadata, or private messages in minutes, then writes back to tamper with balances, escalate their own account, or wipe tables. The May 2025 disclosure of CVE-2025-48757 made the stakes concrete: researcher Matt Palmer found 303 endpoints across 170 AI-generated Lovable projects readable by unauthenticated requests, exposing data for roughly 13,000 users. For your organization the loss is not abstract: a single un-policied table can mean a reportable PII breach, regulatory exposure, customer-trust collapse, and an attacker who can not only read but rewrite the data your product runs on.

At a glance

Typemissing-supabase-rls
Ports443
Protocolshttps
Seen onSupabase, PostgREST, PostgreSQL, Supabase Auth (GoTrue), supabase-js client, Lovable, Bolt, v0, GraphQL via pg_graphql
SeverityCritical
Updated2026-05-28

How attackers find and exploit it

  • Harvest the credentials from the client: load the application, open the browser network tab or read the JavaScript bundle, and extract the Supabase project URL (https://<ref>.supabase.co) and the anon JWT, both of which are present by design in any supabase-js frontend.
  • Enumerate the schema: request the PostgREST root (GET /rest/v1/ with the apikey header) to retrieve the OpenAPI definition, which lists every table and column exposed through the API, then probe the pg_graphql endpoint at /graphql/v1 for the same.
  • Test anonymous read: send GET /rest/v1/<table>?select=* with the anon key as the apikey and Authorization bearer; a 200 with row data instead of an empty array or a 401/permission error proves RLS is not restricting the anon role.
  • Bulk-exfiltrate using PostgREST features: page through every row with Range headers or limit/offset, pull joined data via embedded resource selects (select=*,related(*)), and dump entire tables of user and business data.
  • Test and abuse write access: attempt POST /rest/v1/<table> to insert, PATCH to update, and DELETE against the same endpoint; where INSERT/UPDATE/DELETE policies are also missing the attacker can forge records, flip an is_admin or role column to escalate privileges, or destroy data.

How to detect it on your surface

  • Inventory every table in the public (API-exposed) schema and check the RLS flag: in SQL run select relname, relrowsecurity from pg_class join pg_namespace on relnamespace = pg_namespace.oid where nspname = 'public', and flag any relrowsecurity = false.
  • Cross-reference RLS-enabled tables against actual policies: a table can have RLS on but zero policies, which denies the anon role but is easy to confuse with 'secured'; run select * from pg_policies where schemaname = 'public' and confirm each sensitive table has appropriate per-command policies.
  • Replay your own frontend's anon key against the API from an unauthenticated context (curl, no logged-in session) and list which tables return rows; anything that returns data without a user session is reachable by the public.
  • Audit GRANTs to the anon and authenticated roles: even with RLS, an over-broad table GRANT widens the attack surface, so review which roles have select/insert/update/delete on public-schema tables.
  • Check the dashboard Advisors/Security lints and the 'Enable RLS on new tables' setting to find tables Supabase has already flagged as 'RLS disabled in public schema'.

Detection signals

  • GET /rest/v1/<table>?select=* sent with only the anon apikey returns HTTP 200 with a populated JSON array rather than [] or a 401/permission-denied body.
  • The PostgREST OpenAPI document at /rest/v1/ enumerates tables and columns to an unauthenticated caller, confirming the API surface and schema are publicly introspectable.
  • Supabase dashboard or Security Advisor emits the lint 'RLS Disabled in Public' / 'rls_disabled_in_public' for a table reachable via the API.
  • A write request (POST/PATCH/DELETE to /rest/v1/<table>) with the anon key returns 201/200/204 instead of 401 or a row-level-security policy violation (PostgREST error code 42501).
  • Response headers identify the stack (Server hints, the apikey/Authorization JWT pattern, and the content-profile header), and the JWT decodes to a payload with "role": "anon".

Validate before you report

  • Decode the captured key to confirm it is the anon (publishable) role and not the service_role key, so any access you demonstrate is access available to the entire public, not a privileged backend secret.
  • From a clean, unauthenticated session, issue GET /rest/v1/<table>?select=* with that anon key and confirm real rows return; capture the exact request and the response body as evidence rather than inferring from the OpenAPI listing alone.
  • Confirm the data is sensitive and live (PII, credentials, tokens, financial or private records) and not seed/placeholder data, by inspecting a small redacted sample of the returned rows.
  • Safely test write exposure without mutating production: attempt an INSERT of a clearly-tagged benign canary row or a no-op PATCH and observe whether it succeeds (201/200) or is rejected by an RLS policy (HTTP 401/403, error 42501); if it succeeds, write access is confirmed and the canary should be removed.
  • Tie the finding to a specific table and command (read vs. write) and record the precise reproduction steps, so remediation can be verified against the same request later.

What looks like this but isn't

  • RLS enabled with no policies looks open but is not: such a table denies the anon role and returns [] or a permission error, so an empty array is the secure outcome, not a vulnerability.
  • An intentionally public table (for example a published blog posts or product catalog table with a read-only SELECT policy for the anon role) is reachable by design; confirm with the application owner whether public read is the intended business behavior before flagging.
  • Data returned by a logged-in test session does not prove missing RLS, because a correct policy can legitimately return the authenticated user's own rows; only access from a genuinely unauthenticated anon context counts as a true positive.
  • A 200 response with an empty body or only the row the test identity owns indicates RLS is working as intended and should not be reported as exposure.

Remediation

  • Immediately enable RLS on every affected table: run alter table <schema>.<table> enable row level security; this denies all anon and authenticated access until explicit policies are written, closing the public hole first.
  • Write least-privilege policies per command (SELECT, INSERT, UPDATE, DELETE) scoped to the right role and ownership, for example using (select auth.uid()) = user_id, so each role can only touch the rows it owns; avoid blanket 'true' policies.
  • Move all privileged or trusted-server logic off the client: never ship the service_role key to the browser, and perform admin operations from Edge Functions or a backend that holds the secret key server-side.
  • Tighten role GRANTs so the anon and authenticated roles only have the table and column privileges they actually need, and revoke access to internal tables that should never be API-reachable.
  • Turn on the project-wide 'Enable RLS on new tables' setting and add a database event trigger or migration check so any future table created via raw SQL starts with RLS enabled, preventing regression.
  • Re-run the Supabase Security Advisor and your own unauthenticated anon-key replay after the change to confirm every flagged table now returns [] or a policy denial; rotate keys only if the service_role key was ever exposed (the anon key itself need not be rotated).

Operational checklist

  • Enforce 'RLS enabled by default' organization-wide and treat any public-schema table without RLS as a release blocker in code review and CI.
  • Add an automated check (migration linter or scheduled query against pg_class/pg_policies) that fails the build when a public-schema table lacks RLS or has RLS enabled with zero policies.
  • Keep the service_role key strictly server-side in a secrets manager, scan client bundles and repositories for it, and rotate immediately if it leaks.
  • Continuously replay the public anon key against the live API from an unauthenticated context as part of monitoring, alerting when any unexpected table starts returning rows.
  • Review new and AI/agent-generated features for direct client-to-database access before deploy, since 'vibe-coded' scaffolds frequently create tables via SQL without enabling RLS.
  • Maintain a documented inventory of intentionally public tables and their read-only policies so reviewers can distinguish designed exposure from accidental exposure.

What to do next

Missing RLS is not a theoretical lint warning; it is a live, internet-facing path to read and rewrite your entire database using a key you publish on purpose. The fix is concrete and fast: enable RLS on the exposed table now, write a least-privilege policy for each command, and re-test with the anon key from an unauthenticated session until it returns nothing. Start with the single most sensitive table today, because every hour it stays open is an hour any visitor can copy or corrupt your users' data.

Methodology

Each finding-type guide is built from Legba Recon's real detection and validation logic, reviewed by a named security contributor, and cited against primary sources such as OWASP, CISA, NIST, and MITRE. We update pages when the underlying guidance changes. See our contributors and company.

FAQs.

References.

Weakness references (CWE)

Keep exploring

Your agent needs its Legba.

Read the docs