
The 7 Supabase RLS Mistakes That Expose Your Entire Database
The 7 Supabase RLS Mistakes That Expose Your Entire Database
Supabase makes it dangerously easy to build a full-stack app in an afternoon. You get a Postgres database, auth, storage, and real-time subscriptions out of the box. But that speed comes with a trap: Row Level Security (RLS) is off by default, and most developers either forget to enable it or configure it incorrectly.
The result? Databases that look secure from the frontend but are wide open to anyone with a browser's dev tools and your Supabase project URL (which is public by design).
We have seen this pattern repeatedly while building PreBreach. Our Supabase RLS bypass scanning template consistently finds exploitable misconfigurations across projects built with AI coding tools like Cursor, Bolt, and Lovable. These tools generate functional Supabase code fast, but they rarely generate correct security policies.
Here are the 7 RLS mistakes we see most often, why they are dangerous, and exactly how to fix each one.
Mistake 1: No RLS Policies on Tables at All
This is the most common and most severe mistake. You create a table, insert data from your app, and everything works. You never touch the "Policies" tab in the Supabase dashboard because the app functions fine without it.
What Goes Wrong
When RLS is disabled on a table, any authenticated or anonymous user can read, insert, update, and delete every row in that table using the Supabase client or a direct PostgREST request. Your anon key is embedded in your frontend JavaScript. Anyone can extract it and query your database directly.
The Dangerous Pattern
-- Table created with no RLS
CREATE TABLE user_profiles (
id UUID REFERENCES auth.users(id),
full_name TEXT,
email TEXT,
plan TEXT DEFAULT 'free',
stripe_customer_id TEXT
);
-- No RLS enabled. No policies created.
-- The table is completely open.
An attacker can do this from their browser console:
// Using your public anon key (visible in your frontend bundle)
const { data } = await supabase
.from('user_profiles')
.select('*');
// Returns EVERY user's profile, including emails and Stripe IDs
console.log(data);
The Fix
Always enable RLS immediately after creating a table. Then add explicit policies for each operation.
-- Enable RLS
ALTER TABLE user_profiles ENABLE ROW LEVEL SECURITY;
-- Users can only read their own profile
CREATE POLICY "Users can read own profile"
ON user_profiles
FOR SELECT
USING (auth.uid() = id);
-- Users can only update their own profile
CREATE POLICY "Users can update own profile"
ON user_profiles
FOR UPDATE
USING (auth.uid() = id)
WITH CHECK (auth.uid() = id);
A table with RLS enabled and zero policies denies all access by default. That is the safe starting point. Add policies only for the access patterns you explicitly need.
Mistake 2: Overly Permissive SELECT Policies
Many developers enable RLS and then create a single, overly broad SELECT policy because they need to display some public data (like usernames in a leaderboard or public posts).
What Goes Wrong
A policy like USING (true) on SELECT means every row in the table is readable by everyone. If the table also contains private fields (emails, payment info, internal notes), all of that is exposed too.
The Dangerous Pattern
-- "We need users to see each other's names in the app"
CREATE POLICY "Allow public read"
ON user_profiles
FOR SELECT
USING (true); -- Every row, every column, every user
This exposes all columns to all users. Supabase RLS operates at the row level, not the column level. If a row is readable, every column in that row is readable.
The Fix
Use Postgres views or column-level grants to limit what is exposed. For RLS policies, be as specific as possible.
-- Option 1: Restrictive policy - users see only their own data
CREATE POLICY "Users read own profile"
ON user_profiles
FOR SELECT
USING (auth.uid() = id);
-- Option 2: Create a public view for shared data
CREATE VIEW public_profiles AS
SELECT id, full_name, avatar_url
FROM user_profiles;
-- Grant access to the view, not the table
GRANT SELECT ON public_profiles TO anon, authenticated;
If you genuinely need public read access to a table, ensure that table contains only data you are comfortable making public. Never mix public and private data in the same table with a USING (true) SELECT policy.
Mistake 3: Missing INSERT and UPDATE Policies
Developers often focus on SELECT policies because they are thinking about data display. They forget that without INSERT and UPDATE policies, users can write arbitrary data into the table.
What Goes Wrong
A user can insert rows that belong to other users, overwrite other users' data, or inject malicious content. In the worst case, they can escalate their own privileges by updating their role or plan column.
The Dangerous Pattern
-- Only a SELECT policy exists
CREATE POLICY "Users can read own data"
ON user_profiles
FOR SELECT
USING (auth.uid() = id);
-- No INSERT policy - if RLS is on, inserts are denied (safe by default)
-- But many devs then add this to "fix" the insert:
CREATE POLICY "Allow all inserts"
ON user_profiles
FOR INSERT
WITH CHECK (true); -- Anyone can insert any row
An attacker exploits this:
// Insert a profile for another user or with elevated privileges
await supabase.from('user_profiles').insert({
id: 'victim-user-uuid',
full_name: 'Hacked',
plan: 'enterprise', // Free upgrade
role: 'admin' // Privilege escalation
});
The Fix
Every INSERT policy should verify that the user can only create rows for themselves, and every UPDATE policy should restrict which columns can be modified.
-- Users can only insert their own profile
CREATE POLICY "Users insert own profile"
ON user_profiles
FOR INSERT
WITH CHECK (auth.uid() = id);
-- Users can update only their own profile, and only specific fields
CREATE POLICY "Users update own profile"
ON user_profiles
FOR UPDATE
USING (auth.uid() = id)
WITH CHECK (
auth.uid() = id
-- Prevent users from changing their own role or plan
AND role = (SELECT role FROM user_profiles WHERE id = auth.uid())
AND plan = (SELECT plan FROM user_profiles WHERE id = auth.uid())
);
For sensitive columns like role, plan, or is_admin, consider using a separate table that only your server-side code (via the service_role key) can modify.
Mistake 4: Exposing the service_role Key in Client Code
The service_role key bypasses all RLS policies. It is meant for server-side operations only: backend APIs, cron jobs, webhooks, and admin scripts. It should never appear in any client-accessible code.
What Goes Wrong
If the service_role key is in your frontend bundle, environment variables exposed to the client, or a serverless function that returns it in error messages, an attacker gets full, unrestricted access to your database. RLS does not apply. They can read, write, and delete everything.
The Dangerous Pattern
// In a Next.js client component or a file without "use server"
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY // CATASTROPHIC
);
Or in a .env.local file:
# These are exposed to the browser in Next.js
NEXT_PUBLIC_SUPABASE_URL=https://abc.supabase.co
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY=eyJhbGci... # NEVER prefix with NEXT_PUBLIC_
The Fix
The service_role key should only exist in server-side environment variables (without NEXT_PUBLIC_ prefix in Next.js, without VITE_ prefix in Vite).
# .env.local - correct setup
NEXT_PUBLIC_SUPABASE_URL=https://abc.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGci...anon_key
# Server-side only - no NEXT_PUBLIC_ prefix
SUPABASE_SERVICE_ROLE_KEY=eyJhbGci...service_role_key
// Server-side only (API route, Server Action, etc.)
import { createClient } from '@supabase/supabase-js';
const supabaseAdmin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.SUPABASE_SERVICE_ROLE_KEY // Only accessible server-side
);
Run a search across your codebase right now:
# Check if service_role key is exposed in client code
grep -r "service_role" --include="*.ts" --include="*.tsx" --include="*.js" src/
grep -r "NEXT_PUBLIC.*SERVICE" .env*
Mistake 5: Incorrect auth.uid() Usage
auth.uid() returns the UUID of the currently authenticated user from the JWT. It is the foundation of most RLS policies. But it has edge cases that developers miss.
What Goes Wrong
If a user is not authenticated, auth.uid() returns NULL. In Postgres, NULL = NULL evaluates to NULL (not true), which means the comparison fails and the policy denies access. This is actually safe behavior for authenticated-only tables. The problem arises when developers write policies that accidentally work around this.
The Dangerous Pattern
-- Trying to allow both the owner and "public" posts
CREATE POLICY "Owner or public"
ON posts
FOR SELECT
USING (
auth.uid() = user_id
OR is_public = true
);
This policy is not inherently wrong, but it becomes dangerous when combined with other mistakes:
-- Allows anyone (even unauthenticated) to update public posts
CREATE POLICY "Update own or public"
ON posts
FOR UPDATE
USING (
auth.uid() = user_id
OR is_public = true -- Any anonymous user can now update public posts
);
Another common mistake is comparing auth.uid() to the wrong column:
-- The 'id' column is the post ID, not the user ID
CREATE POLICY "Users read own posts"
ON posts
FOR SELECT
USING (auth.uid() = id); -- Should be auth.uid() = user_id
The Fix
Be explicit about which column represents the owner. Test your policies with and without authentication.
-- Correct: reference the actual user foreign key
CREATE POLICY "Users read own posts"
ON posts
FOR SELECT
USING (auth.uid() = user_id);
-- For public + private content, separate the policies
CREATE POLICY "Anyone can read public posts"
ON posts
FOR SELECT
USING (is_public = true);
CREATE POLICY "Owners can read their own posts"
ON posts
FOR SELECT
USING (auth.uid() = user_id);
-- WRITE policies should NEVER include the "is_public" bypass
CREATE POLICY "Owners update own posts"
ON posts
FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
You can test policies in the Supabase SQL editor:
-- Test as a specific user
SET request.jwt.claims = '{"sub": "user-uuid-here"}';
SELECT * FROM posts;
-- Test as anonymous
SET request.jwt.claims = '{}';
SELECT * FROM posts;
Mistake 6: RLS Disabled for Testing, Never Re-enabled
During development, RLS gets in the way. Queries fail, inserts are rejected, and it is frustrating to debug. So developers disable RLS to "get things working" and plan to re-enable it later. Later never comes.
What Goes Wrong
The app ships to production with RLS disabled on some or all tables. Everything works from the frontend because there are no restrictions. The developer assumes the app is secure because they are only calling Supabase through their application code. They forget that the anon key is public and anyone can make direct API calls.
The Dangerous Pattern
-- "Temporarily" disable RLS for development
ALTER TABLE orders DISABLE ROW LEVEL SECURITY;
ALTER TABLE payments DISABLE ROW LEVEL SECURITY;
ALTER TABLE user_settings DISABLE ROW LEVEL SECURITY;
-- TODO: Re-enable before launch
-- (This TODO will never be resolved)
The Fix
Never disable RLS, even in development. Instead, create permissive development policies that you replace before launch, or use the service_role key in your local development scripts.
-- For local development, use specific dev policies
-- (Only if you absolutely need to bypass RLS during dev)
CREATE POLICY "dev_allow_all"
ON orders
FOR ALL
USING (current_setting('app.environment', true) = 'development');
Better yet, write your RLS policies first and test against them from day one. It is far easier to write correct policies when you have no data than to retrofit them onto a production database.
Add an automated check to your CI/CD pipeline:
-- Query to find tables without RLS enabled
SELECT schemaname, tablename
FROM pg_tables
WHERE schemaname = 'public'
AND tablename NOT IN (
SELECT relname FROM pg_class
WHERE relrowsecurity = true
);
-- If this returns any rows, your deployment should fail
PreBreach's Supabase RLS bypass template runs exactly this kind of check automatically, testing each table for missing or misconfigured RLS policies from an external attacker's perspective.
Mistake 7: Missing Policies on Supabase Storage
Supabase Storage uses the same RLS system as your database tables, but through storage policies on storage.objects. Developers who carefully secure their database tables often forget that their storage buckets are wide open.
What Goes Wrong
Uploaded files (user avatars, documents, private attachments) become accessible to anyone. In some cases, users can upload files to other users' directories, overwrite existing files, or upload malicious content.
The Dangerous Pattern
-- Public bucket with no restrictions
INSERT INTO storage.buckets (id, name, public)
VALUES ('avatars', 'avatars', true);
-- No storage policies created
-- Anyone can upload, download, and delete any file in the bucket
Or overly broad policies:
-- "Let authenticated users upload anything anywhere"
CREATE POLICY "Allow authenticated uploads"
ON storage.objects
FOR INSERT
WITH CHECK (auth.role() = 'authenticated');
-- This lets any user upload to any path, including other users' folders
The Fix
Structure your storage paths to include the user's ID, then write policies that enforce this structure.
-- Create a private bucket
INSERT INTO storage.buckets (id, name, public)
VALUES ('user-files', 'user-files', false);
-- Users can only upload to their own folder
CREATE POLICY "Users upload to own folder"
ON storage.objects
FOR INSERT
WITH CHECK (
bucket_id = 'user-files'
AND auth.uid()::text = (storage.foldername(name))[1]
);
-- Users can only read their own files
CREATE POLICY "Users read own files"
ON storage.objects
FOR SELECT
USING (
bucket_id = 'user-files'
AND auth.uid()::text = (storage.foldername(name))[1]
);
-- Users can only delete their own files
CREATE POLICY "Users delete own files"
ON storage.objects
FOR DELETE
USING (
bucket_id = 'user-files'
AND auth.uid()::text = (storage.foldername(name))[1]
);
For truly public assets (like a company logo), use a separate public bucket with no write access from the client. Upload public assets through server-side code using the service_role key.
The RLS Security Checklist
Before you ship, verify every item on this list:
- Every table in your
publicschema has RLS enabled. Run the query from Mistake 6 to check. - Every table has explicit policies for SELECT, INSERT, UPDATE, and DELETE. Missing policies = denied access (safe default), but verify you have not accidentally created overly broad ones.
- No policy uses
USING (true)orWITH CHECK (true)without a very good reason. If you need it, document why. - Your
service_rolekey never appears in client-accessible code. Search your codebase. auth.uid()comparisons reference the correct column. It should match a foreign key toauth.users(id).- Storage buckets have explicit policies. Check both public and private buckets.
- Sensitive columns (role, plan, permissions) cannot be modified by users through UPDATE policies.
Automate Your RLS Audits
Manually reviewing RLS policies works for small projects. But as your schema grows and you add tables, it is easy to miss one. Automated scanning catches what humans overlook.
PreBreach includes a dedicated Supabase RLS bypass template as part of its AI-powered penetration testing suite. It tests your application from the outside, the same way an attacker would, attempting to bypass RLS policies, access other users' data, and exploit misconfigured storage buckets. Our AI agents understand the context of your Supabase application and generate targeted test cases specific to your schema.
If you are building on Supabase, RLS is your primary security boundary. Make sure it actually works. A single missing policy on a single table can expose your entire user base.

