Tools · · 7 min read

Supabase RLS: Why Your AI Tool Probably Got It Wrong

Row Level Security is critical for Supabase apps. Here's why AI tools consistently misconfigure it.

Supabase has become the default database for vibe-coded applications. Cursor recommends it. Bolt scaffolds it. Lovable integrates it natively. It is a genuinely excellent platform with one feature that matters more than any other for security: Row Level Security, or RLS.

RLS is Supabase’s mechanism for controlling who can read, insert, update, and delete rows in your database tables. Without it, any user with your Supabase anon key (which is public and embedded in your client-side code) can query any table and read every row. With it, access is restricted to exactly the rows each user should see.

Here is the problem: AI coding tools get RLS wrong in approximately 80% of the Supabase applications we audit. Not subtly wrong. Catastrophically wrong. Tables with no RLS. Policies that allow everything. Critical gaps that expose your entire database to any authenticated user.

This post walks through the five most common RLS failures we see, with concrete examples and fixes.

What RLS Does and Why It Is Non-Negotiable

When your Next.js or React application talks to Supabase from the browser, it uses the anon key. This key is public. It is in your JavaScript bundle. Anyone can extract it. The only thing preventing that key from granting full access to your database is RLS.

Think of it this way: RLS is not a nice-to-have feature. It is the authorization layer for your entire application. If your API routes do not check permissions (and in AI-generated code, they often do not), RLS is the only thing standing between an attacker and your users’ data.

Supabase’s anon key is intentionally public. It is designed to be used in browser code. The security model depends entirely on RLS being correctly configured on every table. If RLS is off, the anon key is effectively a master key to your database.

Mistake 1: RLS Disabled Entirely

Critical

RLS Disabled on Tables

Tables have Row Level Security disabled, allowing any user with the anon key to read, insert, update, and delete all rows.

This is the worst case, and it is the most common. During development, AI tools encounter RLS errors when trying to query tables. The error messages from Supabase are clear — the query returned no rows because no RLS policy grants access — but the AI’s solution is to disable RLS rather than write a proper policy.

Before
-- AI "fix" for RLS errors
ALTER TABLE profiles DISABLE ROW LEVEL SECURITY;
ALTER TABLE invoices DISABLE ROW LEVEL SECURITY;
ALTER TABLE documents DISABLE ROW LEVEL SECURITY;

-- Sometimes hidden in migration files
-- that you never reviewed
After
-- RLS must be enabled on every table
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

-- Force RLS even for table owners
ALTER TABLE profiles FORCE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
ALTER TABLE documents FORCE ROW LEVEL SECURITY;

Check your Supabase dashboard right now. Go to Table Editor, click on each table, and look at the RLS badge. If any table shows “RLS disabled”, your data is exposed. This is not a theoretical risk — it is an open door.

How to check: In the Supabase SQL Editor, run:

SELECT tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public';

Any row where rowsecurity is false is a table with RLS disabled. Fix them all before doing anything else.

Mistake 2: Overly Permissive Policies

High

Permissive RLS Policies

RLS is enabled but policies grant access to all authenticated users regardless of data ownership.

This is more insidious than disabled RLS because it looks correct at first glance. The AI enables RLS and writes policies, but the policies are too broad. The most common version grants all authenticated users access to all rows.

Before
-- Looks like it's protected, but isn't
CREATE POLICY "Users can view all invoices"
ON invoices FOR SELECT
TO authenticated
USING (true);

-- Any logged-in user can read
-- every invoice in the system
After
-- Scoped to the row owner
CREATE POLICY "Users can view own invoices"
ON invoices FOR SELECT
TO authenticated
USING (user_id = auth.uid());

-- Users can only read their own invoices

The USING (true) pattern is the telltale sign. It translates to “this policy applies to every row for every authenticated user.” This is appropriate for genuinely public data (a list of countries, for example) but almost never appropriate for user-generated content.

The fix: Every policy should include a condition that ties the row to the requesting user. The most common pattern is user_id = auth.uid(). For team-based applications, you will need to join through a membership table:

CREATE POLICY "Team members can view team invoices"
  ON invoices FOR SELECT
  TO authenticated
  USING (
    team_id IN (
      SELECT team_id FROM team_members
      WHERE user_id = auth.uid()
    )
  );

Mistake 3: Missing Policies on Sensitive Tables

High

Incomplete RLS Coverage

RLS is enabled but no policies exist for some operations, silently blocking legitimate access while providing a false sense of security — or policies exist for SELECT but not INSERT, UPDATE, or DELETE.

AI tools often write a SELECT policy and forget about the rest. RLS works on a per-operation basis: SELECT, INSERT, UPDATE, and DELETE each need their own policies. If you have a SELECT policy but no UPDATE policy, users can read their data but cannot modify it (which breaks your app). If you have a SELECT and INSERT policy but no DELETE policy, users cannot delete their own records.

The more dangerous version: the AI writes SELECT and INSERT policies but omits UPDATE and DELETE. Your app appears to work because reading and creating work. But if you later add update or delete functionality, it will fail silently in some frameworks or, worse, work because of an overly permissive fallback.

Before
-- Only SELECT is covered
CREATE POLICY "Users can view own profiles"
ON profiles FOR SELECT
TO authenticated
USING (id = auth.uid());

-- No INSERT, UPDATE, or DELETE policies
-- User can't update their own profile!
After
-- Complete policy set
CREATE POLICY "Users can view own profile"
ON profiles FOR SELECT
TO authenticated
USING (id = auth.uid());

CREATE POLICY "Users can update own profile"
ON profiles FOR UPDATE
TO authenticated
USING (id = auth.uid())
WITH CHECK (id = auth.uid());

CREATE POLICY "Users can insert own profile"
ON profiles FOR INSERT
TO authenticated
WITH CHECK (id = auth.uid());

-- Deliberately no DELETE policy:
-- profiles should not be deletable

The fix: For each table, explicitly decide which operations each user role should be able to perform, then write a policy for each. If an operation should not be allowed, document that decision in a comment. Absence of a policy should be intentional, not accidental.

Mistake 4: Using the Service Role Key on the Client

Critical

Service Role Key Exposed

The Supabase service role key is used in client-side code, completely bypassing all RLS policies.

Supabase provides two keys: the anon key (public, subject to RLS) and the service_role key (private, bypasses all RLS). When AI tools encounter RLS errors during development, they sometimes switch to the service role key to make the query work. If this key ends up in client-side code, your RLS policies are meaningless.

Before
// CATASTROPHIC: service role key in the browser
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY!
//          ^^^^^^^^^^^ This bypasses ALL RLS
);
After
// Client: always use the anon key
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

// Server only: service role for admin operations
// NEVER expose this in client-side code
import { createClient } from '@supabase/supabase-js';

const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,        // No NEXT_PUBLIC_ prefix
process.env.SUPABASE_SERVICE_ROLE_KEY!  // No NEXT_PUBLIC_ prefix
);

If your service role key has ever been in client-side code or committed to a public repository, rotate it immediately in the Supabase dashboard under Settings > API. Then audit your entire codebase to ensure it is only used in server-side code.

The fix: The service role key should only ever exist in server-side code: API routes, server actions, serverless functions, or backend services. It should never be prefixed with NEXT_PUBLIC_ in Next.js or VITE_ in Vite-based frameworks. If an environment variable is accessible in the browser, the service role key must not be in it.

Mistake 5: No Policies for Delete Operations

Medium

Missing Delete Restrictions

DELETE operations are unrestricted or missing, allowing users to delete other users’ data or preventing legitimate deletions.

Delete policies are the most commonly forgotten. AI tools focus on creating and reading data because that is what most prompts ask about. Delete functionality is added later, and by that point the AI has moved on from thinking about RLS.

The risk is twofold. Without a delete policy, users either cannot delete anything (because RLS blocks the operation) or, if a permissive policy was added earlier, they can delete anything.

Before
-- Overly permissive delete
CREATE POLICY "Anyone can delete"
ON documents FOR DELETE
TO authenticated
USING (true);
-- Any user can delete any document!
After
-- Only owners can delete their documents
CREATE POLICY "Users can delete own documents"
ON documents FOR DELETE
TO authenticated
USING (user_id = auth.uid());

-- For team apps: only admins can delete
CREATE POLICY "Admins can delete team documents"
ON documents FOR DELETE
TO authenticated
USING (
  team_id IN (
    SELECT team_id FROM team_members
    WHERE user_id = auth.uid()
    AND role = 'admin'
  )
);

The fix: Write explicit delete policies for every table. Scope them to the data owner or to admin roles. Test that non-owners cannot delete rows by switching to a different user in the Supabase dashboard and attempting the operation.

How to Test Your RLS

Testing RLS is not optional, and it is not something you can do by using your app normally. You need to actively try to break your own policies.

Step 1: Create two test users. Sign in as User A and create some data. Sign in as User B and attempt to read, update, and delete User A’s data by calling the Supabase client directly.

Step 2: Use the Supabase SQL Editor with the “Run as” dropdown set to your anon role. Try querying each table. You should see only data that your RLS policies allow.

Step 3: Test each operation type independently: SELECT, INSERT, UPDATE, DELETE. A table is only secure when all four operations are correctly restricted.

Step 4: Check for policy gaps using this query:

SELECT
  t.tablename,
  COALESCE(array_agg(p.cmd) FILTER (WHERE p.cmd IS NOT NULL), '{}') AS policies
FROM pg_tables t
LEFT JOIN pg_policies p ON t.tablename = p.tablename
WHERE t.schemaname = 'public'
GROUP BY t.tablename
ORDER BY t.tablename;

This shows which operations have policies on each table. If you see a table with only {r} (SELECT), you are missing write policies.

Get Your RLS Audited

RLS is subtle. Policies interact with each other. A single misconfiguration can undermine an otherwise solid setup. If you are running a Supabase application with real user data, a professional review is not a luxury — it is a necessity.

Our audit packages include a thorough RLS review as part of every Supabase engagement. We test every table, every policy, and every edge case. You can also check our security checklist to start evaluating your setup on your own.

Ready to ship with confidence?

Get your AI-generated app audited by UK security experts.

See Pricing

Or email us at hello@vibecodeaudits.co.uk

Related Articles