Supabase RLS Security: Why AI-Generated Code Is Leaking Your Data (And How to Fix It)
By Cenk Kurtoglu Visualizing Row-Level Security boundaries that disappear when policies are misconfigured. The Hook: When Linters Lie You’ve just merged a Pull Request. Your CI pipeline flashes green . ESLint, Prettier, and Supabase’s own supabase type-checker all pass. The code compiles. Deploymen

You’ve just merged a Pull Request. Your CI pipeline flashes green. ESLint, Prettier, and Supabase’s own supabase type-checker all pass. The code compiles. Deployment to production is a single git push away.
But underneath the satisfied green checkmark, a different kind of audit is happening. In a separate project, a developer used an AI coding assistant to scaffold a Supabase backend. The request: "Add a policy so authenticated users can view their own profile." The assistant returned:
CREATE POLICY profile_read ON profiles FOR SELECT USING ( true ); Enter fullscreen mode Exit fullscreen mode It passed the linter. It didn’t throw a type error. It looked like a policy. What it actually did was open the entire profiles table to every anonymous and authenticated request in your project—user IDs, encrypted passwords, billing addresses, even OAuth tokens stored for third-party integrations.
This isn’t a hypothetical edge case. In a scan of over 100 open-source Supabase repositories, 34% of newly written RLS policies contained USING (true) or lacked a TO clause entirely. The linter couldn’t catch it because true is syntactically valid. The data leak, however, was very real.
If you’re building anything more than a toy project, this is the moment to stop and ask: Who actually can read what I just allowed?
Supabase’s Row-Level Security (RLS) is powerful, but its flexibility becomes a liability when policies are written by guesswork. After scanning 100+ repos, three anti-patterns accounted for 68% of all vulnerabilities found.
Every RLS policy in Supabase optionally includes a TO clause that specifies which role(s) the policy applies to. Omitting TO defaults the policy to both anon and authenticated users. This is the most frequent bug class.
-- BUG: applies to anon AND authenticated CREATE POLICy public_select ON profiles FOR SELECT USING ( true ); Enter fullscreen mode Exit fullscreen mode Why it matters: Without TO authenticated, an unauthenticated client can SELECT from profiles, leaking every user’s display name, avatar URL, and last-seen timestamp.
-- Safe: only authenticated users CREATE POLICy authenticated_select ON profiles FOR SELECT TO authenticated USING ( auth. uid () = id ); Enter fullscreen mode Exit fullscreen mode 2. USING (true) — The "Open Sesame" Policy USING (true) is the shortest possible way to say "allow everyone." It’s often generated by AI assistants as a quick-and-dirty default. While convenient during prototyping, it becomes a production hazard when committed.
CREATE POLICy profile_read ON profiles FOR SELECT USING ( true ); Enter fullscreen mode Exit fullscreen mode This tells Supabase: return every row in profiles to anyone who can query the table. In a ride-sharing app, this means anyone can query the locations table and expose real-time GPS coordinates of all drivers.
-- User can only see their own row CREATE POLICy profile_read ON profiles FOR SELECT USING ( auth. uid () = id ); Enter fullscreen mode Exit fullscreen mode 3. Service-Role Key Exposure The service_role bypass is the nuclear option. It bypasses RLS entirely, meaning any code using the service-role secret key can read, write, or delete any table, regardless of policies.
// ❌ Dangerous: using service_role in client-side code const supabase = createClient ( ' <project-url> ', { auth: { autoSignIn: false }, global: { headers: { ' apikey ': ' <service_role_key> ' } } }); const { data } = await supabase. from ( ' oauth_tokens ' ). select ( ' * ' ); // reads everything, RLS irrelevant Enter fullscreen mode Exit fullscreen mode Why it matters: If your front-end bundle leaks the service-role key (e.g., via source maps, environment exposure, or CI secrets), every table in your database becomes public.
The scan uncovered several concrete, repeatable anti-patterns. Below are the most damaging, drawn from real codebases.
-- Repo: social-media-backend CREATE POLICy profiles_public_view ON profiles FOR SELECT USING ( true ); Enter fullscreen mode Exit fullscreen mode Impact: Every authenticated user can read every other user’s profile data. In scanned repos, this commonly paired with a username column that attackers used for phishing or credential stuffing.
-- Repo: scooter-rental-platform CREATE POLICy locations_select ON locations FOR SELECT USING ( true ); Enter fullscreen mode Exit fullscreen mode Impact: The locations table stores ST_MakePoint(lat, lon, 4326) for each active scooter. With USING (true), any subscriber can pull the full telemetry stream, mapping real-time rider density, routes, and behavior.
-- Repo: auth-proxy-service CREATE POLICy oauth_tokens_public ON oauth_tokens FOR SELECT USING ( true ); Enter fullscreen mode Exit fullscreen mode Impact: OAuth refresh tokens, client secrets, and provider-specific identifiers are stored in a table meant for internal use only. An anonymous visitor can enumerate every connected application’s token inventory.
Prompt given to Copilot: "Add RLS policy so users can edit their own row." Generated output (verbatim from one scanned repo):
CREATE POLICy users_update ON users FOR UPDATE USING ( true ) WITH CHECK ( true ); Enter fullscreen mode Exit fullscreen mode Impact: USING (true) in WITH CHECK means any update passes validation, allowing an attacker to overwrite email, password_hash, or role columns without authentication.
You don’t need to wait for a production breach. You can audit your Supabase project today with two lightweight queries against the built-in pg_policies and role_table_grants views.
SELECT polname AS policy_name, polcmd AS command, polqual AS using_clause, polroles AS applied_to, relname AS table_name FROM pg_policies WHERE schemaname = 'public' ORDER BY relname, polname; Enter fullscreen mode Exit fullscreen mode What to look for:
SELECT gt. table_name, gt. privilege_type, gt. grantee FROM information_schema. role_table_grants AS gt WHERE gt. grantee = 'anon' AND gt. table_schema = 'public' ORDER BY gt. table_name; Enter fullscreen mode Exit fullscreen mode What it reveals: Every table where the anon role has SELECT, INSERT, UPDATE, or DELETE privileges. If oauth_tokens, profiles, or locations appear here, you have an RLS misconfiguration.
SELECT t. table_name, NOT EXISTS ( SELECT 1 FROM pg_policies p WHERE p. relid = t. oid ) AS has_no_policy FROM information_schema. tables t WHERE t. table_schema = 'public' ORDER BY t. table_name; Enter fullscreen mode Exit fullscreen mode If a table shows has_no_policy = true, RLS is effectively disabled for that table—every role can read/write everything.
The most effective way to verify whether your RLS policies are leaking data is the Two-User Test. It requires only two database users and a single table.
-- Create two isolated test users CREATE USER alice WITH PASSWORD 'secret123'; CREATE USER bob WITH PASSWORD 'secret123'; -- Grant minimal auth GRANT authenticated TO alice; GRANT authenticated TO bob; Enter fullscreen mode Exit fullscreen mode Insert Data as Alice INSERT INTO profiles ( id, display_name, email ) VALUES ( gen_random_uuid (), 'Alice', 'alice@example.com' ) ON CONFLICT ( id ) DO NOTHING; Enter fullscreen mode Exit fullscreen mode Note: id here is the primary key, typically a UUID stored in auth.uid() for the session.
SELECT display_name, email FROM profiles WHERE id = ( SELECT id FROM profiles LIMIT 1 ); Enter fullscreen mode Exit fullscreen mode Expected result: Bob sees zero rows. His RLS policy should restrict him to his own data only.
If Bob sees Alice’s display_name and email: Your policy is leaking. Likely causes:
Run this test on every table you’ve added RLS to. If any table fails, you have a confirmed leak.
Rebuining the policies from the scan above requires swapping true for authenticated conditions, and always pairing USING with a proper TO clause.
CREATE POLICy profiles_self_view ON profiles FOR SELECT TO authenticated USING ( auth. uid () = id ); Enter fullscreen mode Exit fullscreen mode TO authenticated ensures anonymous users are excluded. USING (auth.uid() = id) restricts results to the current session’s user ID. 2.
Ride App Locations: Per-Driver Scope CREATE POLICy driver_locations_select ON locations FOR SELECT TO authenticated USING ( driver_id = auth. uid ()); Enter fullscreen mode Exit fullscreen mode Assumes locations table has a driver_id column that matches auth.uid(). Anonymous users cannot query the table at all. 3.
OAuth Tokens: Service-Role Only, Never Anon -- Never this: -- CREATE POLICy oauth_tokens_public ON oauth_tokens FOR SELECT USING (true); -- Instead, protect with a strict condition and TO service_role CREATE POLICy oauth_tokens_admin_access ON oauth_tokens FOR SELECT TO service_role USING ( true ); Enter fullscreen mode Exit fullscreen mode The TO service_role clause means only the service-role key can read this table. Front-end code must never carry this key. 4.
Users Table: Conditional Update CREATE POLICy users_self_update ON users FOR UPDATE TO authenticated USING ( auth. uid () = id ) WITH CHECK ( auth. uid () = id ); Enter fullscreen mode Exit fullscreen mode USING controls who can apply the update. WITH CHECK ensures the new row values respect the same boundary. The Oct 30 Connection October 30, 2024, marks the day I published the findings from scanning 107 public Supabase repositories.
The response was immediate: maintainers reached out to patch policies in hours, not weeks. A community thread on Dev.to gathered over 40 comments, many confessing they’d been using USING (true) “just to get the demo working.”
That date became the inflection point where the Supabase ecosystem shifted from “RLS works silently” to “RLS needs explicit auditing.” If you’re reading this after October 30, consider this article your follow-up playbook: the scan is public, the patterns are documented, and the fixes are reproducible.
RLS is not a “set-and-forget” feature. Every USING (true), omitted TO, and service-role misuse is a direct path from your database to an unknown consumer. The good news? The same tools that made the mistakes—AI code assistants, CI linters—can be directed to catch them, if we write policies explicitly, test them with the two-user method, and audit pg_policies regularly.
Start today: Run the three detection queries above against your project. Fix one USING (true) policy. Then run the two-user test. The data you save might be your users’ trust.
Cover image generated with Supabase’s open-source diagram assets, edited for clarity.