PreBreachPreBreach
How it WorksMethodologyPricingBlog
Start Audit
HomeBlogThe 7 Supabase RLS Mistakes That Expose Your Entire Database
The 7 Supabase RLS Mistakes That Expose Your Entire Database

The 7 Supabase RLS Mistakes That Expose Your Entire Database

2/20/2026
supabaserlsdatabase securityvibe coding

Table of Contents

The 7 Supabase RLS Mistakes That Expose Your Entire DatabaseMistake 1: No RLS Policies on Tables at AllWhat Goes WrongThe Dangerous PatternThe FixMistake 2: Overly Permissive SELECT PoliciesWhat Goes WrongThe Dangerous PatternThe FixMistake 3: Missing INSERT and UPDATE PoliciesWhat Goes WrongThe Dangerous PatternThe FixMistake 4: Exposing the service_role Key in Client CodeWhat Goes WrongThe Dangerous PatternThese are exposed to the browser in Next.jsThe Fix.env.local - correct setupServer-side only - no NEXT_PUBLIC_ prefixCheck if service_role key is exposed in client codeMistake 5: Incorrect auth.uid() UsageWhat Goes WrongThe Dangerous PatternThe FixMistake 6: RLS Disabled for Testing, Never Re-enabledWhat Goes WrongThe Dangerous PatternThe FixMistake 7: Missing Policies on Supabase StorageWhat Goes WrongThe Dangerous PatternThe FixThe RLS Security ChecklistAutomate Your RLS Audits

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:

  1. Every table in your public schema has RLS enabled. Run the query from Mistake 6 to check.
  2. 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.
  3. No policy uses USING (true) or WITH CHECK (true) without a very good reason. If you need it, document why.
  4. Your service_role key never appears in client-accessible code. Search your codebase.
  5. auth.uid() comparisons reference the correct column. It should match a foreign key to auth.users(id).
  6. Storage buckets have explicit policies. Check both public and private buckets.
  7. 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.

Related Articles

We Analyzed Apps Built with Lovable and Bolt — Here Are the Security Vulnerabilities We Found

We Analyzed Apps Built with Lovable and Bolt — Here Are the Security Vulnerabilities We Found

Vibe Coding Security: A Practical Guide to Securing Apps Built with Cursor, Bolt, and Lovable

Vibe Coding Security: A Practical Guide to Securing Apps Built with Cursor, Bolt, and Lovable

Table of Contents

The 7 Supabase RLS Mistakes That Expose Your Entire DatabaseMistake 1: No RLS Policies on Tables at AllWhat Goes WrongThe Dangerous PatternThe FixMistake 2: Overly Permissive SELECT PoliciesWhat Goes WrongThe Dangerous PatternThe FixMistake 3: Missing INSERT and UPDATE PoliciesWhat Goes WrongThe Dangerous PatternThe FixMistake 4: Exposing the service_role Key in Client CodeWhat Goes WrongThe Dangerous PatternThese are exposed to the browser in Next.jsThe Fix.env.local - correct setupServer-side only - no NEXT_PUBLIC_ prefixCheck if service_role key is exposed in client codeMistake 5: Incorrect auth.uid() UsageWhat Goes WrongThe Dangerous PatternThe FixMistake 6: RLS Disabled for Testing, Never Re-enabledWhat Goes WrongThe Dangerous PatternThe FixMistake 7: Missing Policies on Supabase StorageWhat Goes WrongThe Dangerous PatternThe FixThe RLS Security ChecklistAutomate Your RLS Audits

Ready to get started?

Join our team of 5,000+ users who are already transforming their workflow with PreBreach.

5,000+ active users
Get PreBreach Pro

Plans starting from $29/month

PreBreach

Secure your vibe coding. Built for the new generation of AI-assisted developers.

All Systems Operational

Product

  • Pricing
  • Sample Report
  • Documentation

Resources

  • Blog
  • Contact

Connect

  • Twitter / X

© 2026 PreBreach Security. All rights reserved.

Privacy PolicyTerms of Service