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

Short answer: Row Level Security (RLS) is a PostgreSQL feature Supabase uses to control which rows a user can see or change at the database layer. With RLS off, Supabase's auto-generated REST API returns every row to anyone holding the public anon key. AI coding tools routinely scaffold tables but skip enabling it, because a demo works either way.

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.

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 records an insufficient database Row-Level Security policy in Lovable that lets remote unauthenticated attackers read or write arbitrary database tables of generated sites — the same root cause: tables created without RLS enabled, readable by anyone holding the public anon key. An app built this way looks fine in the demo, and no code review would catch it, because the code that's missing (ENABLE ROW LEVEL SECURITY) is defined by its absence — there's no diff to flag.

The same underlying pattern also showed up 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 said publicly on X that 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 pre-ship check catches. We first ran it in July on version 0.5.0, then again on 2026-10-05 on 0.7.1, the current release. The fixture did not change between the two runs; the tool did.

The fixture

A minimal Next.js 14 App Router + Supabase repo, hand-seeded with the two failure patterns above. It is a representative demo we wrote by hand, not code an AI tool produced:

  • 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.local — NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY set to a service-role-shaped value, shipped under the client-exposed NEXT_PUBLIC_ prefix, next to the normal NEXT_PUBLIC_SUPABASE_ANON_KEY.
  • 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 are the findings from the 0.7.1 run, trimmed to the fields that matter (the full, unedited JSON of both runs is committed in content-pipeline/captures/supabase-rls-pillar-071/):

{
  "findings": [
    { "id": "exposed_env", "title": ".env file tracked in git", "severity": "high", "file": ".env.local" },
    { "id": "exposed_env", "title": "Secret exposed via NEXT_PUBLIC_ prefix", "severity": "critical", "file": ".env.local", "line": 2 },
    { "id": "exposed_env", "title": "Secret exposed via NEXT_PUBLIC_ prefix", "severity": "critical", "file": ".env.local", "line": 3 },
    { "id": "hardcoded_secret", "title": "Supabase Service-Role Key (legacy JWT format)", "severity": "critical", "file": ".env.local", "line": 3 },
    { "id": "public_admin_route", "title": "No Next.js middleware.ts found", "severity": "high", "file": "middleware.ts" },
    { "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" },
    { "id": "public_admin_route", "title": "Unprotected admin/dashboard page: app/dashboard/page.tsx", "severity": "high", "file": "app/dashboard/page.tsx" }
  ],
  "riskScore": { "score": 100, "recommendation": "DO_NOT_SHIP", "totalFindings": 7, "criticalCount": 3, "highCount": 4 },
  "filesScanned": 6
}

On shipguard 0.7.1, this fixture produced seven findings, three critical and four high, with the verdict DO_NOT_SHIP. Every finding is marked blocking, so the same command with --strict exits with code 1; without --strict it exits 0 and you read recommendation. Two runs a few seconds apart returned the same seven findings.

What it caught, and what it didn't

The service-role key now has its own finding, twice. On 0.5.0 it had none: the NEXT_PUBLIC_ check only read source files under app/, pages/ and components/, and the generic secret check only looked at variable names containing words like secret or token. On 0.7.1 the NEXT_PUBLIC_ check also reads .env files, and a separate check decodes the key and recognises a JWT whose role claim is service_role. Line 3 of .env.local is flagged by both, which is what you want for the one key that bypasses every policy you write.

Line 2 is a false positive, and we are saying so. NEXT_PUBLIC_SUPABASE_ANON_KEY is public by design: Supabase builds the anon key to ship in the browser, and RLS is what makes that safe. On 0.7.1 the NEXT_PUBLIC_ check flags any variable with KEY in its name, so the anon key draws a critical finding it does not deserve. We have recorded it as a defect in the tool. Until it is fixed, treat a critical on an anon key as noise and a critical on anything named SERVICE_ROLE as real.

The missing ENABLE ROW LEVEL SECURITY still produces no finding. On 0.5.0 that was partly a path problem: supabase/migrations/ was not on the list of folders the database checks treat as migrations, so the file was never read as one. On 0.7.1 the folder is on the list, and we also re-ran the fixture with the file moved to migrations/. Same result both ways: no finding, because the free database checks look for destructive SQL (DROP TABLE, TRUNCATE, an unguarded DELETE) and none of them looks for a table created without RLS. That check exists in our Pro rule corpus (supabase.rls.table-rls-disabled), which this free run did not use, so we make no claim about what it reports here.

The route and page findings did not change. The dashboard page that imports the admin client has no auth guard, and neither does the admin API route behind it. Those are independent problems: an attacker needs only one of them, or only the exposed key.

Green doesn't mean safe, and neither does red for the wrong reason. DO_NOT_SHIP is the right verdict for this fixture, and on 0.7.1 it is earned by the right findings: the exposed service-role key and the unguarded routes. But the one failure this article is about, a table with RLS off, is still invisible to the free gate, so the pg_tables query above stays part of your pre-ship routine.

Manual check vs. automated gate

Manual check Automated gate (shipguard 0.7.1, free)
Effort ~2 minutes, every time you remember to run it One command, every push
Catches missing RLS directly Yes: the pg_tables query or the dashboard banner No; it is a Pro corpus rule, not in the free checks
Catches a committed service-role key Only if you grep for it Yes, twice on 0.7.1 (NEXT_PUBLIC_ exposure and the decoded service-role JWT)
Catches tracked .env files and unguarded routes Only if you look Yes, on every run
Depends on remembering to run it Entirely No; wire it into CI once

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 Firebase security rules teardown. If secrets are the more immediate worry, this secret scan of a vibe-coded app 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