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
· 12 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. In the context of modern development, RLS is the primary security mechanism for platforms like Supabase, 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. This is the fundamental difference between "table-level" security (where you can either see the whole table or nothing) and "row-level" security (where you see only your specific data).
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. 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. This is not a theoretical risk; it is the most common way data leaks occur in the modern AI-stack.
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 a significant risk: in SimplyScan's scans of 177 AI-built apps, 58 of those apps (33%) had at least one HIGH or CRITICAL severity issue, often stemming from these types of authorization gaps. Furthermore, security issues (high) appeared in 19 apps (11%) within that same study group. This is why a vibe coding security checklist is essential for modern developers who are moving fast and relying on LLMs to generate their infrastructure.
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. If you miss any of these steps, you either leave your data wide open or lock yourself out of your own application.
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. If your frontend suddenly returns zero results after running this command, it means the database is working exactly as intended: it is denying access because no permission rules (policies) exist yet.
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. The database extracts the JWT (JSON Web Token) sent from your frontend, verifies it, and makes the user's ID available to your RLS policies.
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. This is the "gold standard" for private user data like profiles, settings, or personal notes.
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. This pattern is common for social media apps where content is public but editing is restricted.
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...). This happens because the database is trying to check the profiles table to see if you are an admin, but to check that table, it needs to run the RLS policy, which then checks the table again.
The solution is a Security Definer function, which runs with the permissions of the creator rather than the user, effectively bypassing the recursion.
Troubleshooting AI-Generated RLS Issues
When tools like Cursor or Lovable generate your database schema, they often default to permissive settings to ensure the app "just works" during the initial build. This "vibe-first" approach is great for prototyping but dangerous for production. Here is how to fix the most common AI-generated RLS failures.
The "Empty Array" Bug
If your frontend is receiving an empty array [] instead of data, the AI likely enabled RLS but forgot to create a SELECT policy. The database is doing its job by denying access. To fix this, verify that a policy exists for the authenticated role and that your USING clause correctly identifies the user. Check your Supabase dashboard under Authentication > Policies to see a visual list of what is active.
The "Infinite Recursion" Error
This happens when an AI writes a policy that queries the same table it is protecting. For example, a policy on the teams table that checks teams.owner_id by performing a SELECT on the teams table. PostgreSQL will throw an error because it gets stuck in a loop. The fix is to use a separate junction table (like user_roles) or a SECURITY DEFINER function as shown in the Admin Policy example above.
The "Update Fails Silently" Issue
If an update query returns success but the data doesn't change, or it fails with a generic error, check your WITH CHECK clause. AI often omits this, or writes a USING clause that allows the user to find the row but a WITH CHECK clause that prevents them from saving the changes because the new data would violate the ownership rule. For example, if a user tries to change the owner_id of a record to someone else, the WITH CHECK clause should block it.
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 after the update is complete.
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 browser history, a log file, or a referral header), your data is wide open to anyone who finds it. RLS ensures that even if an ID is known, only authorized users can access it.
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 or a tool like Postman, your RLS is failing. You can use our API security best practices guide to set up a robust testing workflow that includes both authenticated and unauthenticated states.
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, see who belongs to which team, and potentially find IDs to use in other attacks. Every table in your public schema needs an RLS policy.
5. Over-reliance on TO authenticated
Simply checking if a user is logged in (TO authenticated USING (true)) is rarely enough. This allows any logged-in user to see every other user's data. You must almost always include a specific check against auth.uid() to ensure that User A cannot see User B's private information. This is one of the most frequent "medium" security issues found in AI-built apps.
The Ultimate Vibe Coding Security Checklist
Before you ship your AI-built application, run through this checklist to ensure your database security is solid. Don't assume the AI handled it correctly; verify every step.
- Enable RLS: Is
ALTER TABLE ... ENABLE ROW LEVEL SECURITYrun on every single table in your schema? - No "Select True": Do you have any policies that use
USING (true)on sensitive data? If so, justify why that data needs to be public. - Service Role Protection: Are you sure you aren't accidentally using the
service_rolekey in your frontend? This key bypasses RLS entirely and should only be used in secure backend environments or Edge Functions. - Delete Policies: Did you remember to restrict who can delete rows? Many developers focus on Read/Write and forget that an attacker could wipe their database if Delete is not restricted.
- Audit Functions: Are your security definer functions set to
search_path = public? This prevents search path hijacking, a sophisticated database attack. - Automated Scanning: Have you run a SimplyScan report to check for exposed keys or missing headers? This is the fastest way to catch mistakes before they reach production.
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. As your schema grows, the complexity of managing these policies increases exponentially.
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. In our research, we found that speed issues (medium) appeared in 124 apps (70%), but it is the security gaps that pose the greatest existential threat to a startup.
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. By catching these issues in the IDE, you prevent vulnerable code from ever being committed to your repository.
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.
The transition from a prototype to a production-ready application requires a shift in mindset from "how do I make this work" to "how could someone break this." RLS is your primary tool in that defense. For a complete overview of your app's health · including speed, SEO, and security · run a free scan today and ensure your users' data is protected by more than just a vibe.
Scan your app for RLS issues →
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.