Supabase RLS Security Checklist — 10 SQL Checks
Most Supabase projects have an RLS gap. Run these 10 SQL checks to verify your row level security policies protect your data — not just appear enabled.
Rod
Founder & Developer
If you've built something on Supabase, there's a good chance your Supabase RLS security configuration has at least one gap. Not because you don't understand Row Level Security — you probably do. But because the specific ways it fails in practice are non-obvious, and because AI tools like Cursor, ChatGPT, and v0 generate Supabase schema files that skip RLS on auxiliary tables more often than anyone talks about.
This isn't a tutorial on what RLS is. This is a checklist you run against your own project right now to verify that your row level security policies are protecting your data.
Ten checks. SQL snippets for every single one. Severity labels so you know what to fix first.
What RLS Does (and What It Can't Do)
Row Level Security (RLS) is database-level authorization — it runs on the Postgres server before any data reaches your application. This matters more than you might realize. Even if your application code has a bug, your middleware fails, or someone calls your API in an unexpected way, Postgres-level policies still enforce access control. It's a real safety net.
Supabase's own documentation on RLS is genuinely good. This post doesn't replace it — it's a verification-focused companion. The docs tell you how to configure RLS; this checklist tells you whether you did it correctly.
What RLS can't protect: it doesn't cover your service_role key (which bypasses all policies by design), it doesn't apply to Supabase Storage buckets unless you configure separate storage policies, and it can be bypassed by SECURITY DEFINER functions if those functions don't include their own authorization checks. These gaps are why we have items 6, 7, and 8 on this list. Broken access control at the database layer is the #1 vulnerability category in OWASP's Top 10 — RLS is your primary defense against it in a Supabase project.
With that framing, here are the 10 things to verify in your project.
Why RLS Fails in Practice — Especially in AI-Generated Code
There are two main ways RLS fails. The first is honest misconfiguration: you understand the concept but get a specific policy detail wrong. USING and WITH CHECK protect different operations and many tutorials never explain the difference. Tables added late in development get missed. Placeholders get committed and forgotten.
The second failure mode is the one no competitor content addresses: AI tools generate Supabase schema files that skip RLS on many tables, and the developer never notices.
Cursor, ChatGPT, and v0 generate schema and migration SQL based on patterns in their training data. Those patterns often include ALTER TABLE users ENABLE ROW LEVEL SECURITY and ALTER TABLE posts ENABLE ROW LEVEL SECURITY — the obvious, user-facing tables. But auxiliary tables like analytics, audit_logs, user_settings, invites, and join tables? The training data patterns don't consistently include RLS on those, so the AI doesn't add it. The migration runs, everything works, and you have tables in your public schema with no RLS.
The same tools also commonly generate CREATE POLICY "allow all" ON some_table FOR SELECT USING (true); — a pattern that appears throughout tutorial content and training data as a starting point. It's a real, valid policy. It also lets every authenticated user read every row in that table.
According to Veracode's State of Software Security 2025 report, 45% of AI-generated code contains at least one vulnerability. The common security mistakes in AI-generated code post covers the general landscape — but Supabase RLS misconfiguration is its own specific failure mode that deserves its own audit checklist.
At Data Hogo, our security rules enforce RLS on every single table — no exceptions. It's a documented rule in our own codebase because we built the Supabase scanning agent specifically after seeing RLS misconfiguration come up as the most frequent finding in real projects. That experience is what this Supabase RLS security checklist is built on.
Want a quick check before going through the full checklist? Paste your RLS policies into our free RLS Checker and get an instant analysis — your SQL never leaves your browser.
The Supabase RLS Security Checklist
Here's a summary of all 10 checks before the details. This table is what you come back to when you're sitting in the Supabase SQL Editor and need a quick reference.
| # | Check | Severity | Quick verification |
|---|---|---|---|
| 1 | RLS enabled on every table | Critical | SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public'; |
| 2 | No policies with USING(true) | Critical | SELECT policyname, tablename, qual FROM pg_policies WHERE qual = 'true'; |
| 3 | WITH CHECK on INSERT and UPDATE | Critical | SELECT policyname, tablename, cmd, with_check FROM pg_policies WHERE cmd IN ('INSERT', 'UPDATE') AND with_check IS NULL; |
| 4 | All operations covered per table | High | SELECT tablename, array_agg(cmd) FROM pg_policies GROUP BY tablename; |
| 5 | RLS on non-obvious tables | High | SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND rowsecurity = false; |
| 6 | service_role key not in frontend | Critical | Search codebase for SUPABASE_SERVICE_ROLE_KEY in client files |
| 7 | Storage buckets have policies | Medium | SELECT id, public FROM storage.buckets; |
| 8 | SECURITY DEFINER functions have auth checks | High | SELECT proname, prosecdef FROM pg_proc WHERE pronamespace = 'public'::regnamespace AND prosecdef = true; |
| 9 | Realtime subscriptions are filtered | Medium | Review client-side .on('postgres_changes', ...) calls |
| 10 | Anon role permissions are minimal | Medium | SELECT policyname, tablename, roles FROM pg_policies WHERE 'anon' = ANY(roles); |
Want to skip the manual work? Paste your SQL into our free RLS Checker for instant analysis, or scan your full repo for a complete audit.
1. RLS Enabled on Every Table — Critical
Check: Open the Supabase dashboard and go to Table Editor. The RLS status shows as a toggle per table. Or run this in the SQL Editor to see every table's status at once:
SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;Any row where rowsecurity = false is a table with no row-level protection. Every authenticated user can read, insert, update, and delete every row.
What to look for: Tables added late in development are the most common miss — user_settings, audit_logs, notifications, invites, join tables, anything that wasn't part of the initial schema. AI-generated migration files regularly omit ALTER TABLE secondary_table ENABLE ROW LEVEL SECURITY; for these auxiliary tables even when the primary tables are correctly configured.
Fix:
-- Run for each unprotected table
ALTER TABLE your_table_name ENABLE ROW LEVEL SECURITY;
-- Then add at least one policy, otherwise all rows are hidden by default
CREATE POLICY "users can access own rows"
ON your_table_name
FOR ALL
USING (auth.uid() = user_id);2. No Policies with USING(true) — Critical
Check: Run this query to find every policy that allows all rows through unconditionally:
SELECT policyname, tablename, qual
FROM pg_policies
WHERE qual = 'true';Any result here means every authenticated (or unauthenticated, depending on the role) user can read every row in that table. The Supabase dashboard shows these tables as having RLS enabled — because they do. But the policy allows everything.
What a bad policy looks like:
-- BAD: RLS is on but this policy allows every authenticated user to read every row
CREATE POLICY "allow all reads"
ON orders
FOR SELECT
USING (true);What a correct policy looks like:
-- GOOD: Only the row owner can read their own data
CREATE POLICY "users can read own orders"
ON orders
FOR SELECT
USING (auth.uid() = user_id);This is the finding that surprises people the most. The AI generates USING(true) because it appears throughout tutorial content as a starting point before the access restriction is added. The starting point gets committed and the restriction never comes.
3. WITH CHECK Clause on INSERT and UPDATE — Critical
Check: This is the one most tutorials and AI-generated code miss entirely. Run this to find INSERT and UPDATE policies that are missing a WITH CHECK clause:
SELECT policyname, tablename, cmd, with_check
FROM pg_policies
WHERE cmd IN ('INSERT', 'UPDATE')
AND with_check IS NULL;Any result means a row can be written without the policy verifying that the written data meets the required conditions.
Why this matters: USING controls which existing rows can be read or targeted by a query. WITH CHECK controls what data can be written. Per PostgreSQL's row security documentation, these are separate clauses that protect separate operations. An UPDATE policy with USING (auth.uid() = user_id) but no WITH CHECK lets a user target their own row — but then change user_id to someone else's value, effectively transferring ownership of data to another account.
What a bad policy looks like:
-- BAD: USING restricts which rows can be targeted for UPDATE,
-- but WITH CHECK is missing — a user can change user_id to another user's value
CREATE POLICY "users can update orders"
ON orders
FOR UPDATE
USING (auth.uid() = user_id);
-- Missing: WITH CHECK (auth.uid() = user_id)
-- Without it, a user can move ownership of their row to any user_idWhat a correct policy looks like:
-- GOOD: Both clauses present — reads and writes are protected
CREATE POLICY "users can insert own orders"
ON orders
FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- For UPDATE, you usually want both:
CREATE POLICY "users can update own orders"
ON orders
FOR UPDATE
USING (auth.uid() = user_id) -- which rows can be targeted
WITH CHECK (auth.uid() = user_id); -- what values can be writtenThis is one of the non-obvious details that makes this checklist worth going through even if you're experienced with Supabase.
4. All Operations Covered Per Table — High
Check: For each table with RLS enabled, verify policies exist for every operation your application actually performs. Run:
SELECT tablename, array_agg(cmd ORDER BY cmd) AS covered_operations
FROM pg_policies
GROUP BY tablename
ORDER BY tablename;Cross-reference this output against your application's actual database operations. A table your app writes to needs INSERT and UPDATE policies. A table your app deletes from needs a DELETE policy.
What to look for: A table with a SELECT policy but no INSERT policy will reject all client-side inserts — which might be intentional (server-only writes) or an oversight that's silently breaking your app. A table with SELECT and INSERT policies but no UPDATE policy silently allows any authenticated user who can read a row to update it. These aren't just security gaps — they're functional bugs.
-- Check specific operations for a given table
SELECT policyname, cmd, qual, with_check
FROM pg_policies
WHERE tablename = 'your_table_name'
ORDER BY cmd;5. RLS on Non-Obvious Tables — High
Check: Audit every table in your public schema, not just the ones that store user-generated content. Run:
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
AND rowsecurity = false;What to look for: The tables most often missed are the ones that seem low-risk at first glance. They're not:
subscriptions— contains plan tier, payment status, Stripe customer IDsapi_keys— contains credentials that grant API accessaudit_logs— contains a record of every action in your appuser_settings— contains preference data, often including notification emails and private configinvites— contains pending invite tokens that can be used to join organizationsnotifications— contains message content between users- Join tables (e.g.,
project_members,team_roles) — control authorization across your entire app
A subscriptions table without RLS lets any authenticated user query every subscription record in the database — including other users' plan tiers and payment status. That's a data exposure problem with real consequences.
6. service_role Key Not in Frontend Code — Critical
Check: The service_role key bypasses RLS entirely. It's designed for server-side administrative operations only. Search your codebase for any occurrence of service_role or SUPABASE_SERVICE_ROLE_KEY in files that could reach the browser:
# Search for the service role key in client-facing locations
grep -r "service_role\|SUPABASE_SERVICE_ROLE_KEY" \
src/components/ \
src/app/ \
public/ \
--include="*.ts" \
--include="*.tsx" \
--include="*.js"Also check your environment variable names. Any variable prefixed with NEXT_PUBLIC_ is bundled into the client-side JavaScript bundle and is readable by anyone who inspects your site:
# This would expose the key to every visitor — never do this
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJ...Why this is Critical: The service_role key in a browser is functionally equivalent to having no RLS at all — for every table, for every user. Anyone who can extract it from your client-side bundle can query, insert, update, and delete any row in your database without any policy restrictions. If you find it in a client-side location, rotate it immediately in the Supabase dashboard and move it to a server-only context.
The service_role key should only appear in server-side files (API routes, server components, the worker) and should never be prefixed with NEXT_PUBLIC_.
7. Storage Buckets Have Policies — Medium
Check: Supabase Storage is not covered by table-level RLS. It has its own policy system. Open the Supabase dashboard, go to Storage, then Policies, and verify every bucket has at least one policy configured. You can also query which buckets exist and whether they're public:
SELECT id, public
FROM storage.buckets;A bucket with public = true means any file URL is accessible to anyone with the link — no authentication required. This is appropriate for truly public assets (landing page images, public avatars). It's not appropriate for user-uploaded files, private documents, or anything that should require authentication to access.
What to look for: A bucket set to public with no download restriction means any file uploaded by any user is accessible to anyone with the URL. If users upload private documents, invoices, or profile pictures they expect to be private, and the bucket is public with no policy, those files are exposed. If a bucket is private but has no policies configured, it's completely inaccessible — which breaks your app.
Check that your storage policies are as restrictive as your table policies:
-- View storage policies (RLS policies on storage.objects)
SELECT policyname, cmd, qual, with_check
FROM pg_policies
WHERE schemaname = 'storage'
AND tablename = 'objects'
ORDER BY policyname;8. SECURITY DEFINER Functions Have Auth Checks — High
Check: Postgres functions callable via supabase.rpc() can be configured with SECURITY DEFINER, which means they run as the function's owner rather than the calling user. A SECURITY DEFINER function bypasses RLS on any tables it queries — it's one of the most common sources of false confidence in Supabase security audits.
Run this to find every public function with SECURITY DEFINER:
SELECT proname, prosecdef
FROM pg_proc
WHERE pronamespace = 'public'::regnamespace
AND prosecdef = true;What to look for: Any function in this list bypasses table-level RLS. This is intentional in some cases (admin functions that legitimately need to query across all rows) and accidental in others (a function that was written as SECURITY DEFINER for a different reason and now silently skips your access policies).
For every function in the results, verify that it includes its own authorization check inside the function body:
-- BAD: SECURITY DEFINER with no auth check — bypasses all RLS, any caller gets all data
CREATE OR REPLACE FUNCTION get_user_data(target_user_id uuid)
RETURNS TABLE (id uuid, email text)
SECURITY DEFINER
AS $$
SELECT id, email FROM users WHERE id = target_user_id;
$$
LANGUAGE sql;-- GOOD: SECURITY DEFINER with explicit auth check inside the function
CREATE OR REPLACE FUNCTION get_user_data(target_user_id uuid)
RETURNS TABLE (id uuid, email text)
SECURITY DEFINER
AS $$
BEGIN
-- Only allow a user to fetch their own data, even though this function bypasses RLS
IF auth.uid() != target_user_id THEN
RAISE EXCEPTION 'Unauthorized';
END IF;
RETURN QUERY SELECT id, email FROM users WHERE id = target_user_id;
END;
$$
LANGUAGE plpgsql;9. Realtime Subscriptions Are Scoped Correctly — Medium
Check: Supabase Realtime streams row changes from the supabase_realtime publication. For tables with RLS, Realtime respects your SELECT policies — a user can only subscribe to changes on rows they can read. But the protection only works if your client-side subscription actually filters by the authenticated user.
Review every Realtime subscription in your client-side code. An unfiltered subscription on a table with a policy gap will stream all changed rows to all subscribers:
// BAD: No filter — streams every change on this table to this client
const channel = supabase
.channel('orders')
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'orders' },
(payload) => handleChange(payload)
)
.subscribe();// GOOD: Filtered to only the authenticated user's rows
const channel = supabase
.channel('my-orders')
.on('postgres_changes',
{
event: '*',
schema: 'public',
table: 'orders',
filter: `user_id=eq.${session.user.id}` // only receive own rows
},
(payload) => handleChange(payload)
)
.subscribe();Even with correct RLS policies, an unfiltered subscription has Supabase's server-side filtering do the work — which is fine but means you're downloading change events for rows your app will silently discard. More importantly, if there's a policy gap you haven't caught yet (which is the whole point of this checklist), an unfiltered subscription will expose that gap in real time.
10. Anon Role Permissions Are Minimal — Medium
Check: The anon role represents unauthenticated users — requests made by visitors who haven't logged in. By default, anon has no table permissions. But policies can grant anon access explicitly. Run this to see every policy that applies to unauthenticated users:
SELECT policyname, tablename, roles, cmd, qual
FROM pg_policies
WHERE 'anon' = ANY(roles)
ORDER BY tablename;What to look for: For most apps, the anon role should only have access to genuinely public data — a public_content table, a landing_page_data table, public product listings. It should never have access to users, profiles, orders, subscriptions, or any table containing personal data.
A policy with no explicit TO clause defaults to TO public, which includes both anon and authenticated roles:
-- BAD: This policy applies to both authenticated AND anonymous users
-- because no role is specified — 'public' includes 'anon'
CREATE POLICY "allow reads"
ON profiles
FOR SELECT
USING (true);-- GOOD: Explicitly restrict to authenticated users only
CREATE POLICY "authenticated users can read profiles"
ON profiles
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);How to Automate This Audit
Running through this checklist manually is realistic for a small project with a handful of tables. It gets harder as you add tables. It gets genuinely difficult when you're using AI tools that may add new tables to your schema without RLS — because you won't know unless you re-run the audit.
Data Hogo's Supabase scanning agent runs these 10 checks (plus additional configuration checks) on every Supabase project you connect. Here's what that looks like in practice.
Step 1: Go to datahogo.com and connect your GitHub account. Standard OAuth — read-only access to your code.
Step 2: Select the repository that contains your Supabase project. If your migration files or your supabase/ directory are in the repo, the scanner picks up your schema automatically.
Step 3: Start the scan. The Supabase agent runs alongside the other scan engines — secrets, dependencies, code patterns — in parallel. The whole thing takes about 60 seconds for a typical project.
Step 4: Look at the Supabase-specific findings. You'll see a list of any tables with RLS disabled, any USING(true) policies, any INSERT/UPDATE policies missing WITH CHECK, and any SECURITY DEFINER functions without auth checks. Each finding includes the table name, the specific issue, and the SQL to fix it.
The scan won't tell you that your RLS policy logic is right for your business rules — only you know what "User A can access User B's data" should mean in your app. But it will tell you when the technical configuration has gaps that no policy can protect.
The free plan covers your first 3 scans at no cost. No credit card required.
Check your RLS policies free — paste your SQL and see findings instantly. For a full repo scan including secrets, dependencies, and code patterns, scan your repo free.
Frequently Asked Questions
How do I enable RLS in Supabase?
Open the Supabase dashboard, go to Table Editor, select the table, and toggle Row Level Security to ON. You can also run ALTER TABLE your_table ENABLE ROW LEVEL SECURITY; in the SQL Editor. Enabling RLS is just the first step — you also need to create at least one policy, or no rows will be returned to any client at all (not even the row owner).
What happens if I don't enable RLS in Supabase?
If RLS is disabled on a table, any authenticated user can read, insert, update, and delete any row in that table — regardless of who owns the data. In a multi-tenant app, that means User A can read and modify User B's records without any restriction. This is a Critical severity finding because it leads to full, unrestricted data exposure across all users. Unauthenticated visitors using the anon key have the same access unless the table has other restrictions.
What is USING(true) in a Supabase RLS policy and why is it dangerous?
USING(true) is a valid RLS policy expression that evaluates to true for every row, meaning every authenticated (or anonymous) user can read every row in the table. The Supabase dashboard shows this as RLS being "enabled" — and it technically is — but the policy allows all access unconditionally. It's functionally equivalent to having no RLS for SELECT operations. Run SELECT policyname, tablename, qual FROM pg_policies WHERE qual = 'true'; in your SQL Editor to find any policies like this in your project.
Does Supabase enable RLS by default?
No. Supabase doesn't enable RLS by default when you create a new table. You have to explicitly enable it per table, either through the dashboard or with a SQL command. This is the most common source of RLS gaps — tables created late in development (analytics, settings, audit logs, join tables) that were added after the initial RLS setup and never had it turned on. AI-generated schema files make this more frequent because they reliably add RLS to the tables mentioned in your prompt and often skip the rest.
How do I test if my Supabase RLS policies are working?
The fastest way is to query the Postgres system catalogs in the Supabase SQL Editor. Run SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public'; to see which tables have RLS enabled, and SELECT policyname, tablename, cmd, qual, with_check FROM pg_policies; to audit all your policies. For a full automated audit that also checks SECURITY DEFINER functions, storage buckets, and service role key exposure, you can paste your SQL into our free RLS Checker for an instant check, or scan your full repo with Data Hogo for a comprehensive audit in under 60 seconds.
Can AI-generated Supabase code skip RLS?
Yes, and it happens regularly. Tools like Cursor, ChatGPT, and v0 generate Supabase schema files and migration SQL based on patterns in their training data. Those patterns consistently include RLS on primary tables like users, posts, and profiles, but often omit it on auxiliary tables like analytics, audit_logs, settings, and join tables. They also frequently generate USING(true) policies as placeholders. If an AI tool wrote your Supabase migrations, running the 45% of AI-generated code that contains at least one vulnerability (Veracode, 2025) through this checklist is worth the 10 minutes.
This Supabase RLS security checklist takes about 10 minutes to run manually against your project. Or 60 seconds with a scan.
Related Posts
OWASP A07 Authentication Failures Guide
OWASP A07 authentication failures with real code examples: weak passwords, JWT without expiry, localStorage tokens, no rate limiting, and how to fix each.
OWASP A01 Broken Access Control Guide
Broken access control is the #1 OWASP risk. This guide explains IDOR, missing auth checks, JWT tampering, and how to fix them with real Next.js code examples.
OWASP A02 Cryptographic Failures Guide
OWASP A02:2021 Cryptographic Failures is the #2 web vulnerability. Learn how plaintext passwords, weak hashing, and hardcoded keys expose your users — with real code examples.