Supabase RLS Security: Why AI-Built Apps Keep Shipping With Row Level Security Off
Supabase RLS is off by default on SQL-created tables. Discover why AI-built apps keep shipping with it disabled—and the complete audit checklist to fix it.
Supabase Row Level Security (RLS) is the single most important security control in a Supabase-backed application, and it is disabled by default on every table you create via SQL, migrations, or an ORM. That gap between what developers expect and what Supabase actually configures has been responsible for dozens of data breaches in 2025 and 2026—including CVE-2025-48757, which exposed live data from over 170 Lovable-generated projects through nothing more than the public anon key.
The problem is not Supabase itself. The problem is a predictable pattern: a developer uses an AI coding tool to scaffold a project, the tool generates SQL migrations or ORM schema files, Supabase creates the tables with RLS off, the app ships to production, and the database is readable by anyone who can find the anon key—which lives in every JavaScript bundle. The developer assumed security was configured. It was not.
This guide covers what Supabase RLS actually does, why the default behavior catches so many teams off-guard, how to audit an existing deployment, and how to write the policies that protect a real SaaS product. If you want to understand the broader security pattern of AI-built apps before diving into the specifics, our post on vibe-coding security risks for enterprise teams covers the full attack surface.
What Is Supabase Row Level Security and Why Does It Matter?
Row Level Security is a PostgreSQL feature that evaluates a policy expression on every row before returning or modifying it. When RLS is enabled on a table, every SELECT, INSERT, UPDATE, and DELETE must pass a policy—if no policy matches, the operation is denied by default. When RLS is disabled, any authenticated request to the Supabase Data API can read or write every row in the table, subject only to the permissions granted to the role making the request.
Supabase exposes your PostgreSQL database over a REST API (PostgREST) and a realtime WebSocket. Both services run under the anon and authenticated Postgres roles. The anon key—the public key that ships in every frontend bundle—authenticates as the anon role. Without RLS, the anon role can query every table in your public schema that has GRANT SELECT TO anon, which is often the entire schema. This means an unauthenticated HTTP request to your Supabase project can return your users table, your orders table, and anything else in public.
- →RLS enabled with no policies — DENY ALL. No rows are returned. This is safer than the alternative but means your app is broken until you write policies.
- →RLS enabled with a permissive policy — rows matching the policy expression are returned. A correctly written policy like (auth.uid() = user_id) returns only the requesting user's rows.
- →RLS disabled — ALL rows returned. Any role with SELECT permission reads the entire table. This is the default state for SQL-created tables.
- →RLS bypassed by service role — the Postgres service role key holds the BYPASSRLS privilege and ignores every policy. Never expose the service role key to client-side code.
Why Supabase RLS Is Off by Default on SQL-Created Tables
Supabase's Table Editor in the dashboard enables RLS automatically when you create a table through the UI. When you create a table through a SQL migration file, an ORM schema sync, or the Supabase CLI, RLS is not enabled—PostgreSQL's default behavior takes over, and PostgreSQL does not enable RLS automatically. The distinction matters because almost every real application uses migrations, and almost every AI coding assistant generates SQL migration files.
Starting in May 2026, Supabase began defaulting new projects to restrict public schema access through the Data API—a meaningful improvement. But this change applies to new projects only; existing projects must be migrated manually. Any project created before May 2026, including Lovable, Bolt, Cursor, v0, and Replit-generated projects from the prior two years, retains the old behavior unless the owner explicitly reconfigures it.
- →Migration-based tables: always verify RLS status explicitly after running migrations. The Supabase CLI can surface tables that lack RLS with a schema diff.
- →ORM-generated tables (Prisma, Drizzle, SQLAlchemy): ORMs generate standard PostgreSQL DDL with no RLS configuration. They have no knowledge of Supabase's Data API or PostgREST authorization model.
- →AI-generated migrations: Lovable, Bolt, and similar tools generate SQL migrations. The models generating the SQL do not consistently add ALTER TABLE ... ENABLE ROW LEVEL SECURITY—and when they do add it, they sometimes generate overly permissive policies like USING (true) that grant access to all rows for all users.
- →Supabase-JS in the browser: the client is initialized with the anon key, which is a public credential. Any table without RLS is fully readable from a browser using supabase-js and the anon key alone.
The CVE-2025-48757 Case: What Happened to Lovable-Generated Apps
CVE-2025-48757 is the most well-documented example of the RLS-off pattern at scale. A researcher scanning public Lovable-generated applications found over 170 projects with Supabase backends where the public schema tables had RLS disabled. Because the Supabase anon key is embedded in every Lovable project's client-side JavaScript bundle, any visitor to any of those applications could make a direct HTTP request to the Supabase Data API and read every row in every unprotected table.
The data exposed included user email addresses, order records, and in some cases personal health information and internal business records. The root cause was not a Supabase software vulnerability—it was a configuration failure reproduced at scale by an AI coding tool that generated tables without enabling RLS. Lovable disputed the CVE classification on the grounds that application security is the developer's responsibility. That position is technically defensible and operationally irrelevant to the users whose data was exposed.
- →The anon key is always public: any user who visits your app can extract it from the JavaScript bundle or the network tab in their browser. Treat it as a public identifier, not a secret.
- →RLS is your authorization layer, not the anon key: the correct mental model is that the anon key identifies the calling context (anonymous user), and RLS policies decide what that context is allowed to see.
- →A single table with RLS off can expose the entire schema: in a multi-table app, one unprotected table that joins to others can leak data across the entire schema through a single query.
- →Breaches from this pattern do not require a sophisticated attacker: reading an unprotected table requires nothing more than curl and the anon key from the browser network tab.
The Complete Supabase RLS Audit: What to Check Before You Launch
Running a Supabase RLS audit before launch is a structured process, but it requires checking every layer of the data access stack—not just enabling RLS on each table. Our Supabase security audit service covers this as part of every vibe-coded app review: enabling RLS is step one; writing policies that actually enforce multi-tenant isolation is the rest of the work.
- →Step 1 — List all tables and their RLS status: connect to your Supabase database and run SELECT schemaname, tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public'; Every row where rowsecurity = false is a table with RLS off.
- →Step 2 — Check existing policies for logic errors: a table with RLS enabled but a policy of USING (true) grants access to all rows for all users. Run SELECT schemaname, tablename, policyname, qual FROM pg_policies WHERE schemaname = 'public'; Review every policy where qual is '(true)' or contains no reference to auth.uid().
- →Step 3 — Verify the anon role grant list: run SELECT table_name, privilege_type FROM information_schema.role_table_grants WHERE grantee = 'anon'; Any table the anon role can SELECT, INSERT, UPDATE, or DELETE should have RLS enabled with a policy that restricts access appropriately for unauthenticated users.
- →Step 4 — Check views and SECURITY DEFINER functions: views that join multiple tables may bypass RLS on the underlying tables. Functions with SECURITY DEFINER run as the owner and bypass RLS entirely—audit every such function for accidental data exposure.
- →Step 5 — Confirm the service role key is not in client code: search your source for service_role or SUPABASE_SERVICE_ROLE_KEY. Any match in client-side code (React, Vue, Next.js client components, mobile apps) is a critical finding.
- →Step 6 — Test with the anon key directly: using curl or the Supabase REST API, attempt to read your tables using only the anon key and no auth token. Every table that returns rows without an authenticated session is exposed.
Writing RLS Policies That Actually Enforce Multi-Tenant Isolation
Enabling RLS on a table without writing a correct policy produces a DENY ALL table—nothing is readable. That is safe but not useful. The policies you write determine the actual access control semantics, and there are three patterns that cover the vast majority of SaaS use cases.
- →User-owned rows (most common): enable RLS, then create a SELECT policy with USING (auth.uid() = user_id) where user_id is a column holding the Supabase user UUID. This returns only rows the authenticated user owns. Add an INSERT policy WITH CHECK (auth.uid() = user_id) to prevent users from inserting rows owned by others.
- →Organization-scoped rows (multi-tenant SaaS): create an org_members table mapping user IDs to organization IDs. Write policies using a subquery: USING (org_id IN (SELECT org_id FROM org_members WHERE user_id = auth.uid())). Every row check verifies that the requesting user belongs to the row's organization.
- →Public read, authenticated write: for content that should be publicly readable but writable only by the owner—blog posts, listings, product pages—create a permissive SELECT policy USING (true) and a restricted INSERT/UPDATE policy WITH CHECK (auth.uid() = author_id).
- →Service-level access via Edge Functions: Supabase Edge Functions can use the service role key server-side for administrative operations that legitimately bypass RLS—cross-tenant reporting, admin dashboards, background jobs. Keep this path narrow and explicitly documented. Every Edge Function using the service role key is a potential privilege escalation vector if compromised.
Running DeployReady as Your Automated Security Baseline
Before a manual security review, running DeployReady gives you an automated baseline across the OWASP Top 10, exposed secrets, missing security headers, and Supabase-specific configuration checks. DeployReady is free, MIT-licensed, and runs entirely locally—it reads your project files and environment configuration without sending data anywhere. A single npx deployready command returns a 0–100 readiness score and a prioritized list of findings.
DeployReady catches what automation can catch: exposed anon keys in committed environment files, missing Content-Security-Policy headers, and configuration patterns that indicate RLS is likely disabled. What it cannot do—and what CVE-2025-48757 demonstrates—is verify that your RLS policies are logically correct. A policy that says USING (true) will not trigger a scanner alert because the policy exists. Verifying that the policy actually enforces the intended access control requires a human engineer who understands your data model and the attack paths specific to your application.
Frequently Asked Questions
Is Supabase RLS enabled by default?
For tables created through the Supabase Table Editor in the dashboard, RLS is enabled by default. For tables created through SQL migrations, the Supabase CLI, ORMs like Prisma or Drizzle, or AI coding tools like Lovable and Bolt, RLS is disabled by default. This is standard PostgreSQL behavior—Supabase does not override it for programmatically created tables. Always verify RLS status with SELECT schemaname, tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public'; before deploying to production.
How do I check if RLS is enabled on my Supabase tables?
Connect to your Supabase database using the SQL Editor or a Postgres client and run: SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public'; Each row where rowsecurity is false is a table with RLS disabled. You can also check in the Supabase Dashboard under Database, where each table shows its RLS status. For a combined view of both RLS status and existing policies, query pg_policies joined to pg_tables.
Can my Supabase anon key expose my database if RLS is off?
Yes. The anon key is a public credential—it is embedded in your frontend JavaScript and visible to every user who visits your application. If a table in your public schema has RLS disabled and the anon role has SELECT permission on it, any HTTP request to your Supabase project's REST API with the anon key in the Authorization header returns every row in the table. No authentication, no session cookie, no additional credentials required.
What is the difference between RLS policies and the Supabase service role key?
RLS policies control what data authenticated and anonymous users can access through the Data API. The service role key is a Postgres credential that holds the BYPASSRLS privilege, meaning it ignores all RLS policies and can read and write any data. The service role key is intended for server-side administrative use only—backend APIs, Edge Functions running privileged operations, data migrations. It should never appear in client-side code. Any client holding the service role key bypasses every access control policy in your database.
Do I need RLS if I'm not querying Supabase directly from the frontend?
Yes, for two reasons. First, even if your current architecture routes all database access through a backend API, that architecture can change—a developer adds a client-side Supabase query, a library initializes the anon key, or Supabase Realtime is enabled and subscribes to tables directly. RLS is a defense-in-depth control that remains effective regardless of how the access pattern evolves. Second, the Supabase Data API is always reachable from the public internet as long as your project is active—routing requests through a backend does not disable the direct API endpoint.
How Belsoft Helps Teams Harden Supabase Security
For teams that shipped with a vibe-coding tool or want to harden a Supabase-backed application before onboarding enterprise customers, Belsoft runs a focused security audit covering the full RLS posture: every table's policy logic, view and function bypass paths, client bundle analysis for exposed keys, and Supabase Realtime subscription exposure. We start with DeployReady to surface the automated findings in minutes, then go further with a manual review that no scanner can replicate—testing the actual authorization logic under the conditions an attacker would use.
The output is a ranked findings report with specific remediation SQL for each finding, a retest to confirm fixes before you present the results to customers or security reviewers, and documentation your team can use to maintain the posture as the schema evolves. If you are preparing for a security questionnaire, SOC 2 readiness, or an enterprise customer's procurement review, this is the right starting point. Book a call to discuss your application, or read more about the Belsoft security audit service.
“RLS is not a feature you add to a Supabase app. It is the access control model—and shipping without it is shipping without authorization.”
Written by
Marco Silva
Security Engineer, Belsoft Solutions
More from the blog
Ready to partner?
Let's talk about your company.
30 minutes. No pitch. We talk through how you run today and where AI and automation would help.
Book a free audit
