Row Level Security (RLS) Policies Explained for Beginners
Quick answer: Row-Level Security (RLS) is a PostgreSQL feature that makes the database itself enforce which rows each user can read or modify. In modern AI-built apps (Lovable, Cursor, Windsurf), the frontend often talks directly to the database, making RLS the primary defense against data leaks and unauthorized access.
By Paula C · Kraftwire Software
· 10 min readRow-Level Security (RLS) is a PostgreSQL security feature that allows you to define rules (policies) directly in your database to control which rows a user can see, insert, update, or delete. In modern "vibe-coded" architectures like those built with Lovable, Cursor, or Windsurf, the frontend often talks directly to the database via a public API key. Without RLS, any user with your public URL could read or delete every record in your database; with RLS, the database itself enforces access control at the row level, ensuring users only interact with data they own.
What Is Row-Level Security?
Row-Level Security (RLS) is a granular access control system built into PostgreSQL. Unlike traditional application-level security, where you write if statements in your backend code to check permissions, RLS moves that logic into the database engine itself. According to Supabase, RLS is the primary security mechanism for their platform, controlling every CRUD (Create, Read, Update, Delete) operation at the row level.
Think of a standard database table like a shared spreadsheet. Without RLS, anyone who can open the spreadsheet can see every row. With RLS enabled, the spreadsheet becomes "smart": it looks at who is viewing it and hides every row that doesn't belong to them. Even if an attacker uses a tool like curl to request all data, the database engine filters the results before they ever leave the server.
---
Why RLS is Critical for AI-Built Apps (Windsurf, Cursor, Lovable)
If you are building with tools like Lovable, Bolt.new, or Windsurf, you are likely using a "Backend-as-a-Service" (BaaS) like Supabase. In this model, your frontend code contains a public anon key.
The Exposed API Risk
In a traditional app, a backend server hides the database. In a vibe-coded app, your database is effectively exposed to the internet. As noted by security researchers, without RLS, every row in every table is exposed to every request made with that public key.
When RLS is missing or misconfigured, an attacker can open the browser console and run a command like supabase.from('profiles').select('*') to download your entire user database.
The "Vibe Coding" Security Gap
AI coding assistants are excellent at generating UI and basic logic, but they often prioritize "making it work" over "making it secure." An AI might help you create a beautiful dashboard but forget to enable RLS on the new orders table it just generated. This is why a vibe coding security checklist is essential for modern developers.
---
How RLS Works: The Three-Step Process
Implementing RLS isn't just about clicking a button; it requires a systematic approach to ensure no data leaks.
1. Enable RLS on the Table
By default, PostgreSQL tables have RLS disabled. You must explicitly turn it on. When you do this, PostgreSQL shifts to a "default deny" posture.
Once this command runs, no one can see the data in that table · not even the owner · until a policy is created. This is a common point of confusion for beginners who think they've "broken" their app.
2. Define the Policy Scope
A policy is a SQL rule that returns a true/false value for every row. If the rule is true for a specific row and a specific user, that user can see or modify it.
A policy consists of:
- Command: Which action is being controlled (SELECT, INSERT, UPDATE, DELETE, or ALL).
- Target: Which roles the policy applies to (usually
authenticatedoranon). - USING clause: A check for existing rows (used for SELECT, UPDATE, DELETE).
- WITH CHECK clause: A check for new data being added (used for INSERT, UPDATE).
3. Apply Authentication Context
In Supabase, the database knows who the user is via the auth.uid() function. This is the "magic" that links your database rows to your logged-in users.
---
Common RLS Policy Examples
Basic Ownership Policy
This is the most common policy. It ensures that users can only see rows where the user_id column matches their own ID.
Public Read / Private Write
For a blog or a public profile, you want everyone to see the data, but only the owner to change it.
The "Admin" Policy (Security Definer)
If you have an is_admin flag in a profiles table, you might try to check it directly in an RLS policy. However, this can cause "infinite recursion" (the policy checks the table, which triggers the policy, which checks the table...).
The solution is a Security Definer function, which runs with the permissions of the creator rather than the user.
---
5 Common RLS Mistakes to Avoid
1. Forgetting the WITH CHECK Clause
If you have a FOR UPDATE policy with only a USING clause, a user might be able to change the user_id of a record to someone else's ID, effectively "stealing" or "transferring" the record. Always use WITH CHECK to ensure the resulting data still belongs to the user.
2. Relying on "Hidden" IDs
Some developers think that if they use a long, random UUID for a record, they don't need RLS because "nobody can guess the ID." This is "security by obscurity" and is not a valid defense. If that ID ever leaks (in a URL, a log, or a referral header), your data is wide open.
3. Not Testing as an Anonymous User
Always test your API while logged out. If you can still fetch data from a "private" table using your browser's network tab, your RLS is failing. You can use our API security best practices guide to set up a testing workflow.
4. Ignoring Junction Tables
In many-to-many relationships (like team_members), developers often secure the teams table but forget the team_members link table. If an attacker can read the link table, they can map out your entire user base and their relationships.
5. Over-reliance on TO authenticated
Simply checking if a user is logged in (TO authenticated USING (true)) is rarely enough.
---
Before you ship your AI-built application, run through this checklist to ensure your database security is solid:
- Enable RLS: Is
ALTER TABLE ... ENABLE ROW LEVEL SECURITYrun on every single table? - No "Select True": Do you have any policies that use
USING (true)on sensitive data? - Service Role Protection: Are you sure you aren't accidentally using the
service_rolekey in your frontend? (This key bypasses RLS entirely). - Delete Policies: Did you remember to restrict who can delete rows? (Often forgotten).
- Audit Functions: Are your security definer functions set to
search_path = publicto prevent search path hijacking? - Automated Scanning: Have you run a SimplyScan report to check for exposed keys or missing headers?
---
How to Audit Your RLS Policies Automatically
Manually checking every table for RLS gaps is tedious and prone to human error, especially when your AI assistant is generating dozens of tables an hour.
SimplyScan provides a free security scanner that detects missing RLS policies, exposed API keys, and broken authentication in about 30 seconds. It specifically looks for the "low-hanging fruit" that attackers use to dump databases from Supabase and Firebase apps.
If you are building with Cursor or Windsurf, you can also use our MCP server to integrate security scanning directly into your AI coding workflow. This ensures that every time the AI generates a new database schema, you are alerted if it forgets the necessary RLS protections.
---
Conclusion
Row-Level Security is not an "optional" feature for modern web apps; it is the foundation of your data's safety. While AI tools make it faster than ever to build, they don't always make it safer. By understanding how to write, apply, and test RLS policies, you move from "vibe coding" to professional, secure engineering.
For a complete overview of your app's health · including speed, SEO, and security · run a free scan today.
Scan your app for RLS issues →
FAQ
Do I need RLS if my app already checks permissions in code?
Yes, if your frontend talks directly to Supabase. Your Supabase URL and anon key are visible in the frontend bundle, so anyone can query your tables from the browser console, completely bypassing your application code. RLS is enforced by the database on every query with no exceptions, which makes it the only reliable protection in this architecture.
Will enabling RLS break my app?
Temporarily, yes, and that is expected. Enabling RLS defaults to denying all access, so no one can read or write rows until you add policies. Many developers hit this, get frustrated, and disable RLS entirely, which is the worst outcome. Instead, add your policies immediately after enabling, starting with a SELECT policy scoped to the authenticated user.
Is it safe that my Supabase anon key is public?
Yes, by design, but only if RLS is configured correctly. The anon key is meant to be public and respects RLS policies on every request. The danger is a table with RLS disabled or with permissive policies like USING (true), because then the public key grants access to everything. Never confuse the anon key with the service-role key, which bypasses RLS entirely.
What is the difference between USING and WITH CHECK in a policy?
USING controls which existing rows a user can see or target, applying to SELECT, UPDATE, and DELETE. WITH CHECK validates the data being written, applying to INSERT and UPDATE. UPDATE policies need both: USING decides which rows can be modified, and WITH CHECK ensures the updated row still satisfies the rule, for example that users cannot reassign a row to someone else.
How do I test that my RLS policies actually work?
Log in as one user and try to read another user's data, for example querying user_profiles filtered to a different user_id. A correct policy returns an empty array, not the other user's rows. Repeat the test for insert, update, and delete. This is the most commonly skipped step. SimplyScan can also detect missing and misconfigured RLS automatically on a deployed app.
How do I give admins access without breaking RLS?
Use a security definer function such as has_role(auth.uid(), 'admin') and call it from the policy. Checking a roles table directly inside a policy can cause infinite recursion, because the roles table has its own RLS policies. The security definer function runs with elevated rights, breaking the loop, and keeps role logic in one audited place, ideally a dedicated user_roles table.
Frequently asked questions
Do I need RLS if my app already checks permissions in code?
Yes, if your frontend talks directly to Supabase. Your Supabase URL and anon key are visible in the frontend bundle, so anyone can query your tables from the browser console, completely bypassing your application code. RLS is enforced by the database on every query with no exceptions, which makes it the only reliable protection in this architecture.
Will enabling RLS break my app?
Temporarily, yes, and that is expected. Enabling RLS defaults to denying all access, so no one can read or write rows until you add policies. Many developers hit this, get frustrated, and disable RLS entirely, which is the worst outcome. Instead, add your policies immediately after enabling, starting with a SELECT policy scoped to the authenticated user.
Is it safe that my Supabase anon key is public?
Yes, by design, but only if RLS is configured correctly. The anon key is meant to be public and respects RLS policies on every request. The danger is a table with RLS disabled or with permissive policies like USING (true), because then the public key grants access to everything. Never confuse the anon key with the service-role key, which bypasses RLS entirely.
What is the difference between USING and WITH CHECK in a policy?
USING controls which existing rows a user can see or target, applying to SELECT, UPDATE, and DELETE. WITH CHECK validates the data being written, applying to INSERT and UPDATE. UPDATE policies need both: USING decides which rows can be modified, and WITH CHECK ensures the updated row still satisfies the rule, for example that users cannot reassign a row to someone else.
How do I test that my RLS policies actually work?
Log in as one user and try to read another user's data, for example querying user_profiles filtered to a different user_id. A correct policy returns an empty array, not the other user's rows. Repeat the test for insert, update, and delete. This is the most commonly skipped step. SimplyScan can also detect missing and misconfigured RLS automatically on a deployed app.
How do I give admins access without breaking RLS?
Use a security definer function such as has_role(auth.uid(), 'admin') and call it from the policy. Checking a roles table directly inside a policy can cause infinite recursion, because the roles table has its own RLS policies. The security definer function runs with elevated rights, breaking the loop, and keeps role logic in one audited place, ideally a dedicated user_roles table.