security

The Complete Guide to Supabase Row Level Security for AI-Built Apps

Supabase is the default backend for AI-built apps. Without proper RLS policies, your users' data is one API call away from exposure. Here's how to lock it down.

FinishKit Team15 min read

You built your app with Lovable. It connected Supabase automatically. Your tables are there, your data flows. Everything works. The UI looks polished. Users can sign up, create records, and see their dashboards.

But right now, anyone with your Supabase URL and anon key can read every row in your database. Every user's email. Every private note. Every payment record. Not a bug. A default you didn't know about.

And that anon key? It's in your client-side JavaScript. Open DevTools, check the network tab, and there it is. By design.

Without Row Level Security enabled, your Supabase anon key gives full read and write access to every table in your database. Anyone can open a browser console, paste your project URL and anon key into a Supabase client, and query whatever they want. This is how Supabase works by default.

Why Supabase + AI Tools = Security Time Bomb Without RLS

Supabase is the number one backend for AI-built apps. Lovable uses it as its default database. Bolt connects to it out of the box. Thousands of Cursor projects wire up Supabase on day one. It's the obvious choice: generous free tier, real-time subscriptions, built-in auth, and a clean API that AI tools can scaffold in seconds.

The problem is what happens after the scaffolding.

Supabase's anon key is public by design. It's meant to be in your client-side JavaScript. The docs say this clearly. The security model assumes you'll use Row Level Security to control what that key can access. But AI tools don't set up RLS. They create the tables, wire up the queries, and move on.

The result: your app works perfectly in development. Every query returns the right data. The UI shows the right things to the right users. But the authorization is happening in your React components, not in the database. The database itself is wide open.

170 out of 1,645 Lovable-created web applications had security vulnerabilities that exposed personal data to anyone with a browser (Supabase community audit, 2025). The most common issue: tables without RLS policies, accessible through the publicly exposed anon key.

This isn't a Lovable-specific problem. It's a pattern that repeats across every AI coding tool that works with Supabase. The AI generates working code. It just doesn't generate secure database policies. And because the app appears to work correctly (the right users see the right data in the UI) the developer has no reason to suspect anything is wrong.

Until someone checks.

What RLS Actually Does

Row Level Security is simpler than it sounds. Here's the mental model: RLS adds an invisible WHERE clause to every query.

Without RLS, when your app runs:

SELECT * FROM profiles;

Postgres returns all profiles. Every user. Every row. The anon key has no restrictions.

With RLS enabled and a proper policy, the same query:

SELECT * FROM profiles;

Returns only the rows that match the policy. If the policy says "users can only see their own profile," then each user's query only returns their own row. The database enforces this. Not your app code. Not your API middleware. The database.

This is the critical distinction. RLS runs at the Postgres level. It cannot be bypassed from client code, from the REST API, or from a direct connection using the anon key. It's the last line of defense, and it's the only line that actually matters when your key is public.

Setting up RLS requires two steps:

  1. Enable RLS on the table. This locks the table down. Nobody can access any rows until you create policies.
  2. Create policies that define who can access what. Policies are SQL expressions that run against each row.

That's it. Two steps per table. The SQL is straightforward. Let's walk through the four patterns that cover 95% of use cases.

Four Essential RLS Patterns

These are the patterns you'll actually use. Each one is complete, runnable SQL that you can paste directly into the Supabase SQL Editor.

Pattern 1: Users Can Only Read Their Own Data

This is the most common pattern. Every user has a row in a table, and they should only be able to see their own.

-- Enable RLS on the table
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
 
-- Users can only read their own profile
CREATE POLICY "Users can read own profile"
  ON profiles FOR SELECT
  USING (auth.uid() = user_id);

auth.uid() is a Supabase function that returns the ID of the currently authenticated user from the JWT token. It's extracted on the database side, so it can't be spoofed from the client.

The USING clause is the invisible WHERE clause. Every SELECT against this table will automatically filter to rows where user_id matches the authenticated user.

What happens without this policy: Any authenticated user (or anyone with the anon key) can SELECT * FROM profiles and get every user's data.

What happens with this policy: Each user only sees their own profile. Attempting to query another user's data returns zero rows, not an error. Clean and silent.

Pattern 2: Users Can Insert and Own Their Data

Reading is only half the equation. You need policies for INSERT, UPDATE, and DELETE too.

-- Enable RLS (skip if already enabled)
ALTER TABLE todos ENABLE ROW LEVEL SECURITY;
 
-- Users can read their own todos
CREATE POLICY "Users can read own todos"
  ON todos FOR SELECT
  USING (auth.uid() = user_id);
 
-- Users can insert todos (and the user_id must be theirs)
CREATE POLICY "Users can insert own todos"
  ON todos FOR INSERT
  WITH CHECK (auth.uid() = user_id);
 
-- Users can update their own todos
CREATE POLICY "Users can update own todos"
  ON todos FOR UPDATE
  USING (auth.uid() = user_id)
  WITH CHECK (auth.uid() = user_id);
 
-- Users can delete their own todos
CREATE POLICY "Users can delete own todos"
  ON todos FOR DELETE
  USING (auth.uid() = user_id);

Notice the distinction between USING and WITH CHECK:

  • USING filters which existing rows the user can see or modify. Think of it as the WHERE clause for "which rows does this policy apply to?"
  • WITH CHECK validates the data being written. For INSERT, it checks the new row. For UPDATE, it checks the row after modification.

The UPDATE policy uses both: USING ensures the user can only update rows they own, and WITH CHECK ensures they can't change user_id to someone else's ID during the update.

Pattern 3: Organization-Based Access

Multi-tenant apps need a different pattern. Users should see all data belonging to their organization, not just their own rows.

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
 
-- Org members can read all projects in their org
CREATE POLICY "Org members can read org projects"
  ON projects FOR SELECT
  USING (
    org_id IN (
      SELECT org_id FROM org_members
      WHERE user_id = auth.uid()
    )
  );
 
-- Org members can create projects in their org
CREATE POLICY "Org members can create org projects"
  ON projects FOR INSERT
  WITH CHECK (
    org_id IN (
      SELECT org_id FROM org_members
      WHERE user_id = auth.uid()
    )
  );

The subquery checks the org_members table to verify the current user belongs to the organization. This runs per-row, but Postgres is smart about caching the subquery result within a single request.

Performance note: For high-traffic tables, consider creating an index on org_members(user_id, org_id) to keep the subquery fast. In practice, this pattern handles thousands of rows without any noticeable latency.

Pattern 4: Public Read, Owner Write

Blog posts, product listings, public profiles. Content that anyone can read but only the owner can edit.

ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
 
-- Anyone (including anonymous users) can read published posts
CREATE POLICY "Anyone can read published posts"
  ON posts FOR SELECT
  USING (published = true);
 
-- Authors can read all their own posts (including drafts)
CREATE POLICY "Authors can read own posts"
  ON posts FOR SELECT
  USING (auth.uid() = author_id);
 
-- Authors can insert their own posts
CREATE POLICY "Authors can create posts"
  ON posts FOR INSERT
  WITH CHECK (auth.uid() = author_id);
 
-- Authors can update their own posts
CREATE POLICY "Authors can update own posts"
  ON posts FOR UPDATE
  USING (auth.uid() = author_id);
 
-- Authors can delete their own posts
CREATE POLICY "Authors can delete own posts"
  ON posts FOR DELETE
  USING (auth.uid() = author_id);

Multiple SELECT policies on the same table combine with OR logic. So a user sees published posts from everyone plus all their own posts (including drafts). This is the right behavior without any application logic.

Five Common AI-Generated RLS Mistakes

AI tools make specific, predictable mistakes with RLS. Here are the five you'll encounter most often, each with the broken version and the fix.

Mistake 1: Forgetting to Enable RLS Entirely

This is the most common and most dangerous mistake. The table exists. The queries work. But RLS was never turned on.

-- BROKEN: Table created without RLS
CREATE TABLE user_data (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id UUID REFERENCES auth.users(id),
  content TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);
-- AI stops here. No RLS. Table is wide open.
-- FIXED: Enable RLS immediately after creating the table
CREATE TABLE user_data (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id UUID REFERENCES auth.users(id),
  content TEXT,
  created_at TIMESTAMPTZ DEFAULT now()
);
 
ALTER TABLE user_data ENABLE ROW LEVEL SECURITY;
 
CREATE POLICY "Users can manage own data"
  ON user_data FOR ALL
  USING (auth.uid() = user_id)
  WITH CHECK (auth.uid() = user_id);

How to catch it: The audit query in the next section will list every table without RLS enabled.

Mistake 2: Enabling RLS but Creating No Policies

This is the opposite extreme. The developer (or AI) enables RLS but never creates any policies. Result: nobody can access any rows, including your own app.

-- BROKEN: RLS on, no policies = total lockout
ALTER TABLE todos ENABLE ROW LEVEL SECURITY;
-- No policies created. App returns empty results everywhere.

Your app won't crash. It'll just return empty arrays for every query. Users see a blank dashboard with no data. This is confusing to debug because there are no errors, just missing data.

-- FIXED: Always create policies after enabling RLS
ALTER TABLE todos ENABLE ROW LEVEL SECURITY;
 
CREATE POLICY "Users can manage own todos"
  ON todos FOR ALL
  USING (auth.uid() = user_id)
  WITH CHECK (auth.uid() = user_id);

Mistake 3: Overly Permissive Policies

The AI knows it needs RLS policies, but generates ones that don't actually restrict anything.

-- BROKEN: Policy that allows everything (defeats the purpose)
CREATE POLICY "Allow all access"
  ON sensitive_data FOR ALL
  USING (true)
  WITH CHECK (true);

USING (true) means "this policy matches every row for every user." It's syntactically correct RLS that provides zero security. You've added the ceremony without the protection.

-- FIXED: Restrict to authenticated user's own data
CREATE POLICY "Users can manage own sensitive data"
  ON sensitive_data FOR ALL
  USING (auth.uid() = user_id)
  WITH CHECK (auth.uid() = user_id);

Mistake 4: Using Client-Provided User IDs Instead of auth.uid()

This is a subtle one. The AI writes a policy that checks user_id, but the user_id value comes from the client request instead of the authenticated JWT.

-- BROKEN: Policy looks correct, but the app passes user_id from client
CREATE POLICY "Users can read own data"
  ON data FOR SELECT
  USING (user_id = current_setting('app.current_user_id')::UUID);

If app.current_user_id is set by the application based on a request parameter, any user can set it to another user's ID. The policy checks the value but has no way to verify it's legitimate.

-- FIXED: Always use auth.uid() which is extracted from the verified JWT
CREATE POLICY "Users can read own data"
  ON data FOR SELECT
  USING (auth.uid() = user_id);

auth.uid() is the only trustworthy source of user identity in RLS policies. It comes from the JWT that Supabase Auth issued. It cannot be modified by the client.

Mistake 5: Forgetting DELETE Policies

AI tools almost always create SELECT and INSERT policies. They sometimes create UPDATE policies. They almost never create DELETE policies.

-- BROKEN: Users can read and create, but anyone can delete anything
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
 
CREATE POLICY "Users can read own docs" ON documents
  FOR SELECT USING (auth.uid() = user_id);
 
CREATE POLICY "Users can create docs" ON documents
  FOR INSERT WITH CHECK (auth.uid() = user_id);
 
-- No DELETE policy. If RLS is enabled, nobody can delete.
-- But if someone adds a permissive policy later, it's open season.

The good news: if RLS is enabled and there's no DELETE policy, deletes are blocked by default. The bad news: this often prompts a frustrated developer to add USING (true) to "fix" it, which opens deletion to everyone.

-- FIXED: Explicit DELETE policy for owners only
CREATE POLICY "Users can delete own docs" ON documents
  FOR DELETE USING (auth.uid() = user_id);

Always create policies for all four operations: SELECT, INSERT, UPDATE, DELETE. Be explicit. Don't leave gaps that will get "fixed" with permissive patches later.

Auditing Your Existing RLS Setup

If you have an existing Supabase project, run these queries right now. They'll tell you exactly where your gaps are.

Open the SQL Editor in your Supabase dashboard and run each of these queries. They're read-only and safe to execute on any project.

Find tables without RLS enabled:

SELECT schemaname, tablename
FROM pg_tables
WHERE schemaname = 'public'
  AND tablename NOT IN (
    SELECT c.relname
    FROM pg_class c
    WHERE c.relrowsecurity = true
      AND c.relnamespace = (
        SELECT oid FROM pg_namespace WHERE nspname = 'public'
      )
  );

Every table in the results is completely unprotected. Anyone with your anon key can read and write to it freely.

List all existing policies:

SELECT
  tablename,
  policyname,
  permissive,
  cmd,
  qual AS using_expression,
  with_check
FROM pg_policies
WHERE schemaname = 'public'
ORDER BY tablename, cmd;

Review the output carefully. Look for:

  • Tables with no policies (RLS enabled but no access rules = total lockout)
  • Policies with qual = true (the USING (true) anti-pattern = no real restriction)
  • Missing operations (has SELECT but no DELETE, for example)

Check for the dangerous combination, RLS disabled with data present:

SELECT
  t.tablename,
  (SELECT count(*) FROM information_schema.columns
   WHERE table_schema = 'public' AND table_name = t.tablename) AS column_count,
  c.relrowsecurity AS rls_enabled
FROM pg_tables t
JOIN pg_class c ON c.relname = t.tablename
  AND c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')
WHERE t.schemaname = 'public'
ORDER BY c.relrowsecurity, t.tablename;

This shows you every public table, its column count, and whether RLS is on. Tables with rls_enabled = false are your immediate priorities.

Applying RLS to an Existing Project

If you ran the audit queries and found gaps, here's the systematic approach to fixing them.

Step 1: Identify every table with a user_id column. These are the tables that need user-scoped RLS policies.

SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
  AND column_name IN ('user_id', 'owner_id', 'author_id', 'created_by')
ORDER BY table_name;

Step 2: Enable RLS on every public table. There's no downside to enabling RLS, as long as you create policies immediately after.

Step 3: Create policies starting with the most sensitive tables. Prioritize tables containing user data, payment information, authentication tokens, or personal details. Apply one of the four patterns above based on your access model.

Step 4: Test with the anon key. Open a new browser tab, create a Supabase client with just your URL and anon key (no auth), and try to query each table. Every query should return zero rows or an error. If you get data back, your RLS is incomplete.

// Quick test: create an unauthenticated client and try to read data
import { createClient } from "@supabase/supabase-js";
 
const supabase = createClient(
  "https://your-project.supabase.co",
  "your-anon-key"
);
 
// This should return ZERO rows if RLS is working
const { data, error } = await supabase.from("profiles").select("*");
console.log("Rows returned:", data?.length); // Should be 0

If that returns any data, you have an unprotected table.

How FinishKit Catches RLS Gaps

This is exactly the kind of issue that slips through in AI-built apps. The app works. The UI is correct. But the database is exposed.

FinishKit scans your Supabase schema as part of its security analysis. It identifies tables without RLS enabled, flags overly permissive policies like USING (true), and checks that all CRUD operations have corresponding policies. These findings show up in the security category of your Finish Plan with specific SQL fixes.

Closing

RLS isn't optional when you're using Supabase. It's not a nice-to-have or a "we'll add it later" item. It's the difference between "my app works" and "my users' data is safe."

The anon key is public. The database is accessible from the client. The only thing standing between your users' data and the open internet is whether you took 15 minutes to write the right policies.

Four patterns cover almost every use case. The audit queries above will show you exactly where your gaps are. The fix is straightforward SQL that you can apply right now.

Don't wait for someone to DM you that they can see everyone's data. Run the audit. Apply the policies. Lock it down today.

For the broader security picture beyond RLS, the guide to AI code security vulnerabilities covers the five most common flaws in AI-generated code. And if you're preparing to ship, the production readiness guide walks through everything from error handling to deployment configuration. For a complete pre-launch rundown, check the AI code finishing checklist.