AgenticCLI
▮ shipguardis a free, local secure-ship agent for AI-built apps — 5 checks in ~2s.$ npm i -g @agenticcli/shipguard

Supabase RLS: The Security Check Every AI-Built App Skips

12 min · jul 2026

A founder pushed a Supabase app to production last month. The demo worked. Signup worked, checkout worked, the dashboard loaded instantly. Three weeks later someone pasted https://theirapp.com/rest/v1/orders?select=* into a browser with no login, no token, nothing — and got every customer's name, email, and order total back as JSON. No exploit. No injection. Just a URL.

Row Level Security was off. It's usually off. Almost nobody who ships a Supabase app with AI assistance turns it on, because nothing in the demo ever asks them to.

Answer capsule: Row Level Security (RLS) is a PostgreSQL feature Supabase uses to enforce, at the database layer, which rows a given user can see or change. Supabase auto-generates a public REST API for every table in your database — with RLS off, that API returns every row to anyone holding the project's anon key, which ships in your frontend JavaScript by design. AI coding tools scaffold the CREATE TABLE statement but routinely skip the ENABLE ROW LEVEL SECURITY line, because it's a separate, easy-to-forget step that has no effect on whether the demo works. It only matters once a second user shows up.


What Row Level Security actually is

Skip the Postgres manual for a second. Here's the plain-English version.

A normal SQL query returns whatever the query asks for. If your backend runs SELECT * FROM orders, it gets every order in the table — that's the query's job, and the database does exactly what it's told. Authorization — deciding which rows a specific user is allowed to see — is usually your application's problem, not the database's. You write the WHERE user_id = ? clause yourself, in every single query, in every route, forever.

RLS moves that check into the database itself. You attach a policy to a table — a small SQL rule like USING (auth.uid() = user_id) — and PostgreSQL enforces it on every single query against that table, no matter who wrote the query or how they connected. Forget the WHERE clause in your API route? Doesn't matter. The database already filtered it. Someone queries the table directly through Supabase's auto-generated REST API? Same policy, same enforcement, no code path to forget.

That's the whole idea: move the security boundary from "every place a human remembered to write it" to "the one place the database always checks."

Supabase didn't invent RLS — it's a native PostgreSQL feature that's existed since version 9.5. What Supabase did was build a public REST and realtime API directly on top of every table in your public schema, which means RLS stopped being a nice-to-have for multi-tenant systems and became the only thing standing between "anon key in a browser tab" and "your whole database."

Why AI-built apps ship with it off

Nobody sits down and decides to skip RLS. It happens because of how these tools actually work.

Ask an AI coding assistant to "add an orders table," and it does exactly that: a CREATE TABLE statement, maybe an index on user_id, done. It got you a working table. Your query against it returns data immediately — no policy blocking it, no confusing "0 rows returned" while you're mid-debug. From the model's perspective, the task succeeded. ENABLE ROW LEVEL SECURITY isn't part of CREATE TABLE syntax; it's a separate statement that Postgres doesn't require and that produces no visible difference in a local dev environment where you're the only user hitting the table anyway.

Then there's the trickier failure mode: you do turn RLS on, and now your own signup flow breaks with new row violates row-level security policy for table "orders". That's not a bug — it means RLS is doing its job and there's no policy allowing the insert yet. But if you're moving fast and just want the demo to work, the fastest fix an agent reaches for is the one that makes the error go away fastest: reach for the service_role key, which bypasses RLS entirely, and use it from wherever the error was thrown — sometimes a server action, sometimes (worse) directly in a client component, because the agent pattern-matched "use the admin client" without distinguishing where that code runs.

Your AI didn't decide to expose your database. It pattern-matched a working path through a thousand tutorials where the fastest fix for "policy blocked my insert" is the client that ignores policies — and handed you the average. That's not malice. That's a confident intern who's never watched a table with RLS off get scraped in production.

The exact failure

Here's the mechanism, concretely.

Every Supabase project ships two keys to the client: anon (safe, meant to be public, designed around RLS enforcing what it can reach) and service_role (never meant to leave your server — it bypasses RLS entirely, by design, for admin operations). Supabase also auto-generates a REST endpoint for every table: https://<project>.supabase.co/rest/v1/<table>.

With RLS disabled on a table, that endpoint answers anon-key requests with the full table contents. No login required — the anon key is baked into every page load of a public web app. With RLS enabled and no policy defined, the same endpoint answers with an empty array — locked down by default, arguably too locked down (which is exactly what pushes people toward the shortcut above).

The two things that turn "annoying empty array" into "everyone's data, unauthenticated" are:

  • RLS disabled entirely on a table that holds anything other than public data.
  • A policy that exists but is effectively USING (true) — technically "on," functionally identical to off, because it grants access unconditionally regardless of who's asking.

Both produce the identical outward symptom during development: the query works, the data comes back, ship it. Both produce the identical outward symptom in production: anyone with the URL and the anon key — which is to say, anyone with a browser and your app's page source — gets the same result.

The 2026 context

This isn't a hypothetical. It's the dominant vulnerability class in AI-built apps right now, and the data backs it up.

CVE-2025-48757 documented 303 exposed endpoints across 170 production apps built on Lovable — 10.3% of the 1,645 apps analyzed — all traced to the same root cause: tables created without RLS enabled, readable by anyone holding the public anon key. Every one of those apps looked fine in the demo. None of them had a code review that would have caught it, because the code that's missing (ENABLE ROW LEVEL SECURITY) is defined by its absence — there's no diff to flag.

That wasn't an isolated incident. In May 2026, the security firm RedAccess published results from scanning roughly 380,000 apps built across vibe-coding platforms including Lovable, Base44, Replit, and Netlify — reported by Security Boulevard and covered by VentureBeat. About 5,000 of those apps were actively leaking sensitive data — medical records, financial documents, internal chat transcripts — with the same structural cause named across the reporting: these platforms default new projects to publicly accessible data, and the person prompting the build often doesn't know there's a setting to change.

The same underlying pattern also showed up outside any single platform's scan numbers, in a single app. Security researchers at Wiz found a misconfigured Supabase database behind Moltbook, an AI-agent social platform, exposing 1.5 million API authentication tokens and 35,000 email addresses — full read and write access to the entire production database, from an anon key sitting in client-side JavaScript with no RLS behind it. The platform's creator told Wiz he "didn't write a single line of code" himself.

The common thread across every one of these isn't a sophisticated exploit. It's a database that was never told which rows belong to which user, answering every request the same way it would answer its own owner.

The manual check

You can check this yourself in about two minutes, no tooling required.

In the Supabase dashboard: go to Table Editor. Any table showing an "RLS disabled" warning banner is fully open to the anon key right now. Click into Authentication → Policies for a second view — a table with zero policies attached is either fully open (RLS off) or fully closed (RLS on, no policy — safer, but probably means a route in your app is silently failing).

From the command line, this SQL tells you the real state directly:

select
  schemaname,
  tablename,
  rowsecurity as rls_enabled
from pg_tables
where schemaname = 'public';

Any row with rls_enabled = false is a table the anon key can read and write to without restriction, right now, in production.

In your codebase, grep for the shortcut that usually caused it:

grep -rn "SUPABASE_SERVICE_ROLE" app/ components/ src/ 2>/dev/null
grep -rn "NEXT_PUBLIC.*SERVICE_ROLE" . --include="*.env*" 2>/dev/null

Any hit inside a client component, a 'use client' file, or an environment variable prefixed NEXT_PUBLIC_ (or VITE_, EXPO_PUBLIC_ for other frameworks) means the key that bypasses every RLS policy you've written is sitting in the JavaScript bundle your browser downloads on every page load.

The fix

Enabling RLS is one line per table:

alter table orders enable row level security;

That line alone locks the table down completely — including from your own app, until you add policies describing who's allowed to do what. That's intentional; a table with RLS on and zero policies denies everyone, which is the correct fail-closed default.

A working policy set for a typical orders table, scoped to the owning user:

-- Users can read their own orders
create policy "select_own_orders"
  on orders for select
  to authenticated
  using (auth.uid() = user_id);

-- Users can create orders for themselves only —
-- WITH CHECK validates the row being inserted, USING does not apply to INSERT
create policy "insert_own_orders"
  on orders for insert
  to authenticated
  with check (auth.uid() = user_id);

-- Users can update only their own, and can't reassign ownership on the way out
create policy "update_own_orders"
  on orders for update
  to authenticated
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

Three details that catch people every time:

  • USING and WITH CHECK are not the same clause. USING filters which existing rows a query can touch. WITH CHECK validates what a new or modified row looks like after the write. An INSERT policy with only USING does nothing at all — Postgres silently ignores USING on inserts, because there's no existing row to filter yet. If you copy a SELECT policy's USING clause onto an INSERT policy and stop there, you've written a policy that looks correct and enforces nothing.
  • auth.jwt() -> 'user_metadata' is not a safe place to check a role. user_metadata is writable by the user themselves through supabase.auth.updateUser(). A policy like using (auth.jwt()->'user_metadata'->>'role' = 'admin') can be defeated by any authenticated user setting their own role to admin. Use app_metadata (server-controlled only) or a Custom Access Token Hook for anything resembling a permission check.
  • USING (true) is not "RLS with extra steps." It's a policy that grants unconditional access to every row, which is functionally identical to RLS being off for that operation. It's correct on a genuinely public, read-only table — a product catalog, say — and wrong everywhere user data lives.

The automated gate

Manual checks work until someone forgets to run them, which is the entire reason this class of bug exists in the first place. So we built a real fixture and ran it through @agenticcli/shipguard to see, honestly, what a local scan actually catches today.

The fixture

A minimal Next.js 14 App Router + Supabase repo, hand-seeded with the two failure patterns above:

  • supabase/migrations/0001_create_orders.sql — a real Supabase-style migration: CREATE TABLE orders (...), no ENABLE ROW LEVEL SECURITY statement anywhere in the file.
  • .env.localNEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY set to a service-role-shaped value, shipped under the client-exposed NEXT_PUBLIC_ prefix.
  • lib/supabase-admin.ts — a Supabase client built from that key, imported by both a 'use client' dashboard page and an unguarded app/api/admin/orders/route.ts handler.

The scan

npx @agenticcli/shipguard scan --json

Here is the real, unedited output:

{
  "findings": [
    {
      "id": "exposed_env",
      "title": ".env file tracked in git",
      "severity": "high",
      "file": ".env.local",
      "blocking": true
    },
    {
      "id": "public_admin_route",
      "title": "No Next.js middleware.ts found",
      "severity": "high",
      "file": "middleware.ts",
      "blocking": true
    },
    {
      "id": "public_admin_route",
      "title": "Unprotected admin/dashboard page: app/dashboard/page.tsx",
      "severity": "high",
      "file": "app/dashboard/page.tsx",
      "blocking": true
    },
    {
      "id": "auth_route_without_guard",
      "title": "Unprotected API route handler: app/api/admin/orders/route.ts",
      "severity": "high",
      "file": "app/api/admin/orders/route.ts",
      "blocking": true
    }
  ],
  "riskScore": { "score": 100, "recommendation": "DO_NOT_SHIP", "totalFindings": 4, "highCount": 4 },
  "filesScanned": 6
}

Four findings, zero critical, four high, verdict DO_NOT_SHIP. The full unedited JSON is committed alongside this article for anyone who wants to reproduce it.

What it caught, and what it didn't

Here's the part that matters more than the verdict: the free-tier scan did not produce a finding that names the RLS-disabled migration or the exposed NEXT_PUBLIC_ service-role key directly. Neither of the two patterns we seeded on purpose triggered its own dedicated finding. We checked why, rather than guess.

The service-role key sat unflagged because of where the free tier's secret-content checks actually look: the NEXT_PUBLIC_-exposure check scans app/, pages/, and components/ source files for a literal NEXT_PUBLIC_X=value assignment written directly in code — it doesn't parse .env file contents at all. And the generic secret-pattern check requires the variable name to contain a small fixed list of words (api_key, secret, token, password, auth_key, access_key); SERVICE_ROLE_KEY doesn't match any of them. We re-ran the same fixture with the .env.local values quoted, in case that was the blocker — identical four findings, confirming quoting wasn't the variable.

The migration went unflagged because supabase/migrations/ isn't in the free tier's list of recognized migration paths (prisma/migrations/, drizzle/, migrations/, db/, and root-level .sql files) — so the file wasn't even classified as a migration, and the destructive-SQL checks that do exist (DROP TABLE, TRUNCATE, unguarded DELETE) never ran against it. Even if it had run, none of those checks look for a missing ENABLE ROW LEVEL SECURITY statement — that's not a rule in the free tier at all. It's a documented rule in our Pro corpus (supabase.rls.table-rls-disabled, targeting exactly this CREATE TABLE + missing-RLS pattern), gated behind the paid tier's authenticated rule fetch.

What the free scan did catch was the adjacent, real signal: the .env.local file itself, tracked and readable, flagged as HIGH — telling you a secrets file exists and needs manual review, even though it doesn't tell you which line inside it is the dangerous one. And it caught, correctly, that the dashboard page reading from that admin client has no auth guard at all, and neither does the API route sitting behind it. Those are genuine, independent findings — an attacker doesn't need the service-role key exposed and an unguarded route to have a problem; either one alone is enough, and the scan caught both of the ones it's built to catch.

Green doesn't mean safe, and neither does red for the wrong reason. A DO_NOT_SHIP verdict here is correct — this fixture should not ship — but it's earned by the auth findings, not by an RLS-specific rule that doesn't exist yet in the free tier. That's the honest state of things today, not the marketing version.

Manual check vs. automated gate

Manual check Automated gate (shipguard scan)
Time ~2 minutes, every time you remember to run it Under a second, every push
Catches missing RLS directly Yes — pg_tables query or dashboard banner Not in the free tier today; supabase.rls.table-rls-disabled is a Pro rule
Catches exposed .env / unguarded routes Only if you're specifically looking Yes, in the free tier, on every scan
Depends on remembering to run it Entirely No — wire it into CI once
Requires Supabase dashboard access Yes No — runs against your local files

Gate it before you deploy

npx @agenticcli/shipguard scan

Run it locally before every deploy, or wire --strict into CI so a push that fails the gate never reaches review:

- name: ShipGuard security gate
  run: npx @agenticcli/shipguard scan --strict

It runs on your machine, on your files. Nothing is uploaded. For the RLS-specific rule shown above — and the rest of the Supabase and Postgres rule family (permissive USING (true) policies, missing WITH CHECK on inserts, user_metadata in a policy, SECURITY DEFINER functions without a pinned search path) — that's the part of the corpus behind the Pro tier, fetched over an authenticated API call at scan time, never stored on disk.

If you're building on Firebase instead, the equivalent gap has a different shape and its own teardown here. If secrets are the more immediate worry, this one walks through the same kind of real scan against a hardcoded Stripe key. For the full rule set and how the CLI-first gate fits into a release process, see ShipGuard.

FAQ

What is RLS in Supabase?
Row Level Security is a native PostgreSQL feature that lets you attach access-control policies directly to a table, so the database itself enforces which rows a given user can read or write — regardless of which code path, API route, or direct REST call is asking. Supabase auto-generates a public REST API for every table in your `public` schema, which makes RLS the primary security boundary for any table holding non-public data, not an optional hardening step.
How do I enable RLS in Supabase?
Run `alter table your_table_name enable row level security;` in the SQL editor or a migration file, once per table. That alone denies all access until you add policies. Then create policies for each operation you want to allow — typically `SELECT`, `INSERT`, and `UPDATE` scoped to `auth.uid() = user_id` — using `create policy` statements. The Supabase dashboard's Table Editor shows a warning banner on any table where RLS is currently off.
Why does Supabase say "new row violates row-level security policy for table"?
This means RLS is enabled on the table but no policy allows the `INSERT` you just attempted — the database is doing exactly what it's supposed to do. It's not a bug to route around with the `service_role` key; it means you need an `INSERT` policy with a `WITH CHECK` clause that matches the row you're inserting (commonly `with check (auth.uid() = user_id)`). Reaching for the service-role key to make this error disappear is the single most common way RLS protection gets silently defeated in AI-built apps.
Is it safe to expose the Supabase `anon` key in my frontend code?
Yes — the `anon` key is designed to be public and ships in every client bundle by default; Supabase's own documentation confirms it's meant to be exposed. It's only safe because RLS is expected to be enabled on every table it can reach. The key that must never appear in client-side code, a `NEXT_PUBLIC_`/`VITE_`/`EXPO_PUBLIC_`-prefixed environment variable, or a public repository is the `service_role` key, which bypasses RLS entirely by design.
What is a Supabase RLS checker?
It's a tool — command-line or dashboard-based — that checks whether Row Level Security is enabled and correctly policy-scoped across your Supabase tables, rather than requiring you to inspect each table by hand. Supabase's own dashboard flags tables with RLS disabled. A CLI-based checker that runs as part of your deploy pipeline catches the same gap automatically, before a push reaches production, rather than depending on someone remembering to look.
Does disabling RLS ever make a table genuinely safe?
Only for data that's meant to be fully public and read-only — a product catalog or a public content feed, for instance — and even then, an explicit `USING (true)` policy with RLS enabled is the clearer, more auditable way to express "this is intentionally open," because it shows up in your policy list rather than as an absence. For any table holding user-specific, private, or financial data, there's no configuration where RLS-off is the correct answer.

────────[ ▮ gate ]────────

Don't ship the next one.

Free, local, no account. Catches this exact bug class before deploy.

$ npx @agenticcli/shipguard scan