By Cenk Kurtoglu
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. 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);
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?
The Three Core Bug Classes
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.
1. TO-less Policies
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.
Anti-pattern:
-- BUG: applies to anon AND authenticated
CREATE POLICy public_select ON profiles FOR SELECT USING (true);
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.
Fix pattern:
-- Safe: only authenticated users
CREATE POLICy authenticated_select ON profiles FOR SELECT
TO authenticated
USING (auth.uid() = id);
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.
Anti-pattern (profiles table):
CREATE POLICy profile_read ON profiles FOR SELECT USING (true);
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.
Fix pattern:
-- User can only see their own row
CREATE POLICy profile_read ON profiles FOR SELECT
USING (auth.uid() = id);
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.
Anti-pattern (frontend code):
// ❌ 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
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.
Fix pattern:
- Never embed
service_rolein client-side code. - Use Edge Functions or Background Jobs for admin operations.
- Store the key in environment variables accessed only by server-side runtimes.
Real Patterns From the 100+ Repo Scan
The scan uncovered several concrete, repeatable anti-patterns. Below are the most damaging, drawn from real codebases.
Profiles with using(true) and No TO
-- Repo: social-media-backend
CREATE POLICy profiles_public_view ON profiles FOR SELECT USING (true);
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.
Ride App Exposing Live Locations
-- Repo: scooter-rental-platform
CREATE POLICy locations_select ON locations FOR SELECT USING (true);
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.
OAuth Tokens Anon-Readable
-- Repo: auth-proxy-service
CREATE POLICy oauth_tokens_public ON oauth_tokens FOR SELECT USING (true);
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.
AI Tools Writing using(true) Policies
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);
Impact: USING (true) in WITH CHECK means any update passes validation, allowing an attacker to overwrite email, password_hash, or role columns without authentication.
How to Detect Your Own RLS Blind Spots
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.
Query 1: Inspect All Policies
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;
What to look for:
-
using_clause=true→ immediate red flag. -
applied_tois null or contains bothanonandauthenticatedwithout an explicitTOclause → policy defaults to everyone. - Table names you didn’t intend to expose (e.g.,
oauth_tokens,payment_records).
Query 2: Check What Anonymous Can Read
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;
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.
Query 3: Find Tables Without Any Policy
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;
If a table shows has_no_policy = true, RLS is effectively disabled for that table—every role can read/write everything.
The Two-User Test: Prove Insecurity in Minutes
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.
Setup
-- 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;
Insert Data as Alice
INSERT INTO profiles (id, display_name, email)
VALUES
(gen_random_uuid(), 'Alice', 'alice@example.com')
ON CONFLICT (id) DO NOTHING;
Note: id here is the primary key, typically a UUID stored in auth.uid() for the session.
Query as Bob
SELECT display_name, email FROM profiles
WHERE id = (SELECT id FROM profiles LIMIT 1);
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:
-
USING (true)withoutTO authenticated - Missing
auth.uid() = idcondition - Policy applied to
anoninstead ofauthenticated
Run this test on every table you’ve added RLS to. If any table fails, you have a confirmed leak.
Fix Patterns: From Anti-Pattern to Production-Ready
Rebuining the policies from the scan above requires swapping true for authenticated conditions, and always pairing USING with a proper TO clause.
1. Profiles: Authenticated-Only Self-View
CREATE POLICy profiles_self_view ON profiles FOR SELECT
TO authenticated
USING (auth.uid() = id);
-
TO authenticatedensures 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());
- Assumes
locationstable has adriver_idcolumn that matchesauth.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);
- The
TO service_roleclause 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);
-
USINGcontrols who can apply the update. -
WITH CHECKensures 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.
Takeaway
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.
Further Reading
- rls.cenkkurtoglu.com – Full scan dataset, policy templates, and a free RLS health-check tool.
- dev.to/cekuu35 – Deep-dive video walkthrough of the two-user test and real-world remediations.
Cover image generated with Supabase’s open-source diagram assets, edited for clarity.

Top comments (0)