The Supabase RLS Trap: How One Missing Toggle Exposes Your Entire Database
A case-study breakdown of how missing Row Level Security turns Supabase's public anon key into a read-write key for your whole database, anchored in CVE-2025-48757 and the auto-generated PostgREST API.

A startup ships a customer dashboard in a weekend. The front end talks to Supabase directly from the browser, the way the quickstart shows. Months later, a researcher opens the page, copies one key out of the page source, points `curl` at the auto-generated REST endpoint, and pages through every customer's name, phone number, subscription tier, and saved API token. No login. No exploit chain. No clever payload. The key was supposed to be public. The wall that should have stood behind it was never built.
That wall is Row Level Security, and in Supabase it is not a defense-in-depth nicety. It is the entire authorization model for client-side access. When it is missing, the value proposition of the platform inverts: the same architecture that lets you build fast lets anyone read your database fast. This post is a field breakdown of exactly how that happens, grounded in CVE-2025-48757, the real disclosure that found this pattern across hundreds of live applications, and what it takes to detect and close it for good.
The anon key is public by design, and that is fine
Every Supabase project ships with two API keys. The `service_role` key is privileged, bypasses all row-level checks, and must never leave your server. The `anon` key is the opposite: it is meant to be embedded in browser JavaScript, mobile apps, and anything else that runs on a device you do not control. Supabase's own disclosure language in CVE-2025-48757 describes it as a "non-sensitive and unprivileged" key. Treating the anon key as a secret is a category error, like calling a building's street address a password.
Under the hood, the anon key authenticates a request as the Postgres `anon` role. That role has no `BYPASSRLS` attribute. Every query it issues is supposed to be filtered by the row-level policies you write. The architecture is genuinely elegant when it works: the client speaks SQL-shaped REST directly to the database, the database enforces who can see what, and you delete an entire tier of bespoke API code. The catch is in the conditional. "Supposed to be filtered" assumes filters exist.
The anon key is not the vulnerability. A public key is the intended design. The vulnerability is publishing that key against tables where Row Level Security was never enabled, which turns an unprivileged role into an unrestricted one.
Where every row leaks: the auto-generated Data API
Supabase wraps your Postgres database in PostgREST, which automatically generates a RESTful endpoint for every table and view in the exposed schema, typically `public`. You do not write this API. It appears. A table named `customers` becomes reachable at `/rest/v1/customers`, supporting filters, ordering, pagination, inserts, updates, and deletes, all driven by query parameters. This is the feature people love. It is also the exact surface that gets exposed.
On existing projects, Supabase's default grants compound the problem. As the platform's own Securing your API guide states, tables you create in `public` are automatically granted `SELECT`, `INSERT`, `UPDATE`, and `DELETE` to the `anon`, `authenticated`, and `service_role` roles. So the moment a table lands, it is reachable through the Data API. Whether that reachability is safe depends on one thing and one thing only:
Any granted table without RLS enabled can be accessed by roles with matching Data API grants (for example, anon). Always make sure RLS is enabled, or that you've got other controls in place to avoid unauthorized access to your project's data.— Supabase Docs, Securing your API
Read that carefully. Without RLS, the unprivileged anon role inherits the full table grant. `SELECT` means read every row. `INSERT`, `UPDATE`, and `DELETE` mean write and destroy them. The auto-generated endpoint will happily serve a `GET /rest/v1/customers?select=*` to anyone holding the public key, because nothing in the chain ever asked who they were. This is precisely the failure modeled in our missing Supabase Row Level Security exposure, and it is structurally identical to an unauthenticated API endpoint wearing a database for a hat.
RLS is the boundary, and its default is deny
The fix is not subtle. Row Level Security is a native Postgres primitive, and Supabase positions it as the authorization layer for all client-side access. Per the Supabase RLS documentation, enabling it is a single statement:
alter table public.customers enable row level security;
The crucial property is what happens next. Once RLS is enabled, no data is accessible via the API until you create a policy. The default posture flips from allow-all to deny-all. This is the inversion you want: an empty, locked table is the safe state, and you deliberately open narrow, intentional paths with policies that bind to `auth.uid()` for the `authenticated` role. A typical owner-scoped policy looks like the following, granting users access only to their own rows.
create policy "Users read own rows" on public.customers for select to authenticated using ((select auth.uid()) = user_id);
The distinction between the `anon` and `authenticated` roles is where most real bugs live. A policy scoped `to authenticated` does nothing to stop a request made with the bare anon key on a table that still has a permissive or missing policy elsewhere. Even with RLS enabled, sloppy policies, for example one that uses `using (true)` to "just make it work," recreate the exact exposure they were meant to prevent. Enabling the toggle is necessary; writing correct, role-scoped policies is the actual job.
CVE-2025-48757: the trap at scale
On May 29, 2025, CVE-2025-48757 was published against Lovable, an AI app-building platform that generates Supabase-backed front ends. The official description is blunt: "An insufficient database Row-Level Security policy in Lovable through 2025-04-15 allows remote unauthenticated attackers to read or write to arbitrary database tables of generated sites." MITRE assigned it a CVSS base score of 9.3, Critical.
The mechanism is everything we have just described. As the original disclosure by Matt Palmer explains, Lovable "creates frontend applications that make direct REST API calls against the database from the client, using a public and unprivileged anon key, while relying exclusively on RLS to ensure the privacy and integrity." When the generated RLS policies did not match the app's actual access logic, or were absent, the public anon key became a read-write key to arbitrary tables. Secondary reporting on the disclosure described sensitive fields exposed across affected projects, including names, phone numbers, payment details, and API keys. The point is not the number of apps; it is that a single missing or wrong policy, multiplied by an automated generator, produced the same critical flaw over and over.
Lovable disputed the CVE, arguing that securing application data is the customer's responsibility, and the NVD record reflects that dispute. Both things can be true at once: the platform default and the developer's policies share the blame. For a defender, the assignment of fault is irrelevant. What matters is that the data was reachable, and that the same pattern is reproducible in any hand-built Supabase project the instant someone forgets a toggle. We unpack the broader generator-driven version of this in our exposed-secrets analysis of the AI-coding era.
An insufficient database Row-Level Security policy in Lovable through 2025-04-15 allows remote unauthenticated attackers to read or write to arbitrary database tables of generated sites.— NVD, CVE-2025-48757
This is broken object level authorization in a new wrapper
Strip away the Supabase branding and this is a textbook authorization failure. The OWASP API Security Top 10 ranks Broken Object Level Authorization as API1, the single most common API risk, because the server returns objects without verifying the caller is allowed to see that specific object. A missing RLS policy is BOLA at the database layer: the request asks for row ID 4172, and Postgres returns it because no policy ever checked whether this caller owns row 4172.
We track this failure class directly in the broken object level authorization exposure, and it explains why fixing Supabase RLS is not a one-table chore. Every table reachable through the Data API is an object endpoint. Each one needs its own policy reasoning. The blast radius also rarely stops at one table, because the same public key that reads `customers` will read `invoices`, `sessions`, and `audit_logs` if they too were left open. This is the same systemic problem behind exposed secrets in frontends and the wider category of exposed environment files: client-side trust placed where only server-side enforcement belongs.
How to detect a missing-RLS exposure
Detection is refreshingly deterministic, which is why it belongs in continuous discovery rather than a one-off audit. The signals are observable from outside, and validating them produces a finding you can act on rather than a maybe.
- Find the project URL and anon key. Both are designed to be public, so they sit in your front-end bundle, network requests, or page source. Locating them is reconnaissance, not exploitation, and overlaps with broader asset discovery.
- Enumerate the Data API. Query the auto-generated OpenAPI document at the project's `/rest/v1/` root with the anon key. It lists the tables exposed through PostgREST, giving you a target inventory without guessing names.
- Probe each table read-only. Issue a bounded `select` such as `GET /rest/v1/<table>?select=*&limit=1` with the anon key as both `apikey` and `Authorization: Bearer`. A 200 with row data on a table that should be private is the exposure.
- Distinguish empty from open. An empty array can mean RLS is correctly denying you, or that the table is simply empty. Confirm against a table you know holds data, and treat any returned row that should require authentication as a validated finding.
- Check writes carefully and ethically. The read result already proves the boundary is missing; do not attempt destructive writes against systems you do not own. On your own projects, a permitted unauthenticated `insert` confirms the `INSERT` grant is live.
The reason this matters for an external attack surface management program is the contrast with conventional scanning. A generic vulnerability scanner might flag "API key found in JavaScript" and generate noise, because the anon key is supposed to be there. That alert is a false positive in isolation. The validated version, the same key returning private rows from a real table, is a confirmed breach path. The difference between those two outcomes separates a false positive in security scanning from a real one: a scanner reports the key, while exposure validation proves the boundary is gone.
| Signal | Scanner noise | Validated finding |
|---|---|---|
| Anon key in front-end bundle | Flagged as exposed secret | Expected, by design, ignore alone |
| Table reachable via /rest/v1/ | Reported as open endpoint | Only matters if it returns data |
| select=* returns private rows | Often missed entirely | Confirmed missing-RLS exposure |
| Empty array response | Logged as accessible | Likely RLS denying access correctly |
The correct fix, in order
Remediation is not just flipping one switch; it is establishing a default-deny posture across the whole Data API surface and keeping it that way as the schema grows.
- Enable RLS on every table in the exposed schema, not only the one that leaked. Run `alter table <schema>.<table> enable row level security;` for each. With no policies attached, the table immediately returns nothing to the anon role, the safe default.
- Write explicit, role-scoped policies. Scope read and write policies `to authenticated` and bind them to ownership with `(select auth.uid()) = user_id`. Reserve any `to anon` policy for data that is genuinely meant to be world-readable, and document why.
- Audit the grants themselves. RLS filters rows, but the role still needs the table grant to reach the API at all. For tables that should never be client-reachable, revoke the anon grant outright rather than relying solely on policies.
- Keep the service_role key server-side. It bypasses RLS entirely. If it ever reaches a browser bundle, every policy you wrote becomes decorative. Treat its exposure with the same urgency as an exposed database server.
- Make the check continuous. New tables ship constantly, and each one starts reachable. A single forgotten migration reopens the hole, which is why continuous attack surface monitoring beats a quarterly review.
Default-deny is the goal, not default-allow with patches. Enabling RLS with zero policies is a safe table. A permissive policy like using (true) is an open table wearing a security toggle. Audit the policies, not just the switch.
There is good news on the platform side. Supabase has announced a breaking change that makes Data API exposure opt-in for new projects, revoking the automatic grants that made forgotten tables reachable by default. That hardens the future. It does not retroactively protect the projects already running on the old default, which is exactly the population CVE-2025-48757 found. If you operate or assess Supabase apps built before that shift, assume allow-all until you have proven otherwise.
Why this belongs in your attack surface, not your backlog
A missing-RLS Supabase table is not a theoretical weakness waiting on a chained exploit. It is a publicly reachable, unauthenticated read-write interface to your production data, discoverable with a key you handed to the browser on purpose. It sits in the same severity tier as an exposed MongoDB instance or an exposed Elasticsearch node, with the added trap that the failing component looks like it is working perfectly from the inside.
For a security team or MSSP, the job to be done is simple to state and hard to do by hand: continuously know which of your Supabase tables return data to an unauthenticated request, and confirm it before the cost is measured in a disclosure. That is validation, not scanning, and it is exactly the kind of confirmed, low-noise finding Legba Adversary is built to surface across your whole attack surface.
Keep reading
More field breakdowns on the exposures that hide in modern, client-heavy stacks.
The Vibe-Coding Security Crisis: How AI-Generated Apps Ship Critical Vulnerabilities
AI-generated apps are shipping without RLS, rate limiting, or auth. We break down CVE-2025-48757 and the real failure patterns behind the vibe-coding wave.
13 min readExposed Secrets in the AI Era: .env Files, Hardcoded Keys, and the Breaches That Follow
How secrets leak through committed .env files, statically served config, and frontend bundles, and the real breaches that followed when attackers found them first.
13 min readFrom Scanner Noise to Validated Findings: Killing False Positives in External Recon
Scanners over-report by design. Here is why false positives drain security teams and MSSPs, and a discipline for validating exposures before you report them.
13 min readFind your open Supabase tables before someone else does
Legba Adversary validates missing Row Level Security from the outside, returning confirmed read paths instead of scanner noise so your team fixes what is actually exposed.
