BrainGrid
How-tos

What Is Row-Level Security? The Auth Hole in Your Vibe-Coded App That Isn't a Code Bug

What is row level security, in plain language for vibe coders: the Supabase auth hole that leaks other users' data is a missing access criterion, not a code bug.

BrainGrid Team
12 min read
What Is Row-Level Security? The Auth Hole in Your Vibe-Coded App That Isn't a Code Bug

Your app has a login screen, so you assumed it was secure. That login screen is the reason you never checked whether user B can read user A's data.

This is the most common way a vibe-coded app leaks. Not a dramatic exploit. Not a clever attacker. Just a database table that, by default, hands every row to anyone who asks, while a polished login screen out front convinces you the door is locked. The lock is on the front door. The filing cabinet inside is wide open, and every logged-in user has a key to all of it.

The fix has a name that sounds like something only a database administrator should care about: row-level security, or RLS. It is not. If you built anything on Supabase, Postgres, or a similar backend without knowing what an RLS policy is, this is the single most important security concept you are missing. And the reason it keeps getting missed is not that it is hard. It is that nobody told the agent to turn it on, because nobody wrote down that each user should only see their own rows. The hypothesis of this post is simple: a missing RLS policy is not a bug the AI wrote wrong, it is an access rule you never specified, and the durable fix is writing that rule down as a criterion the build gets checked against.

#What row-level security actually is

Picture your database table as a shared spreadsheet. You have an orders table, and every row has a user_id column saying who it belongs to. Without row-level security, that spreadsheet has one rule: if you can open it, you can read every row. All of them. Yours, mine, and every other customer's.

Row-level security changes that one rule. It is a policy you attach directly to the table that says, in effect, "a user can only see the rows where user_id matches their own." The database itself enforces it, on every single query, automatically. As one engineer put it plainly on LinkedIn:

Normally if you can read a table, you can read every row in it. RLS changes that. You stick a rule on the table and Postgres does the filtering for you per query based on whoever's asking.

That last part is what makes it powerful and what makes its absence dangerous. The filtering happens at the database layer, below your application code, below your API, below the login screen. It does not matter how the request arrives. A user hitting your app, a script hitting your API directly, a third-party tool with a stolen token: the database applies the same policy to all of them. Turn RLS off, and you are trusting every layer above the database to remember to filter by user, every single time, forever. Turn it on, and the database refuses to hand over rows that are not yours, no matter who forgot what.

Here is the surprise most vibe coders hit. On Supabase, a brand-new table has RLS enabled but with no policies, which means it denies everything until you write a rule, but the moment you flip a table to public or attach a permissive policy to make your app "just work," you can accidentally open it to the entire internet. The failure is rarely that a hacker broke your encryption. It is that the table was configured to trust anyone, and the app looked fine in the demo because you were the only user, so you never saw anyone else's data leak into your view. There was only your data to see.

#Why this hole is the one that shows up every time

Search "vibe coding security" and every vendor page and AI Overview lists the same top offender: databases like Supabase deployed without row-level security policies. It is not a coincidence that it ranks first. When an engineering manager reviewed 25 vibe-coded apps, the same handful of holes appeared in all of them, and missing RLS was near the top of the list every time. On r/vibecoding, the recurring question is not academic. Someone asked the whole community, flatly, how are you handling security in your vibe-coded projects today? and the honest answers reveal a lot of people who just found out their filing cabinet was open.

The reason it recurs is worth sitting with, because it explains why buying a scanner will not save you. The agent building your app is optimizing for the same thing you asked for: a working demo, fast. A table with no access restrictions is the fastest path to a screen that shows data. Restricting rows to their owner is extra work the agent will happily skip if you never mention it, because skipping it makes the demo appear sooner and nothing visibly breaks while you are the only user. As one builder said in a widely shared post, most AI-generated apps are not hacked because of AI, they are hacked because basic security is skipped. Skipped, not failed. The agent did not get RLS wrong. It was never told RLS was part of the job.

#The reframe: it's an access criterion, not a code bug

Here is where it clicks, and it is the same move we made in our post on vibe coding security. Missing row-level security feels like a technical bug, something gone wrong in the code that a good enough programmer would have caught. It is not. It is a decision about who is allowed to see what, and that decision was never made out loud, so the agent made it for you, silently, in favor of the demo.

Watch what happens when you make the decision explicitly.

Vague: "Build an orders page where users can see their orders."

Specified: "Build an orders page. Each user can see only their own orders, never another user's. Enforce this at the database with a row-level security policy on the orders table matching user_id to the logged-in user, not just by filtering in the app. A direct query to the database by any other user must return zero rows."

Same feature. The second version turned an invisible assumption into a written access rule. It named the enforcement layer, the database, not the UI, so the agent cannot satisfy it by hiding rows in the frontend while leaving them readable through the API. And, crucially, it gave you something you can check without reading a line of the auth code. "Log in as user A, then try to fetch user B's orders directly. Do you get zero rows?" is a test anyone can run. "Is the authorization logic correct?" is not a test a non-engineer can run. The first question is the durable one. The second is the trap.

That distinction, between a criterion you can verify and a code review you can't perform, is the whole reason this matters more now than it did five years ago. When an agent writes the code and you cannot read it, the security you can actually rely on is the security you can specify and test from the outside. RLS is a perfect example: you may not be able to audit the policy's SQL, but you can absolutely confirm that user B gets zero rows. Put yourself in the path there. That is the gate.

#Where this fits in the loop

This is exactly the gap BrainGrid is built to close. You describe the feature in plain language, and the Planning Agent turns it into a requirement with explicit acceptance criteria, including the access rules you would otherwise leave in your head: who owns each row, who is allowed to read or change it, and where that rule is enforced. A criterion like "each user sees only their own orders, enforced at the database with row-level security" becomes part of the standard the Builder Agent builds against, whether that build happens in the Managed sandbox or in your own GitHub repo through Claude Code, Cursor, or Codex over MCP. It is not decoration. A feature that leaves the table wide open does not quietly pass, because "enforced at the database, other users get zero rows" is written down as a criterion, and a feature is not done until every criterion is verified with evidence.

That is the loop: Plan the access rules along with the feature, Build against them, Verify each one with evidence, Repeat. The RLS hole that scanners find, and that reviews of vibe-coded apps keep finding, lives in the gap between "the demo shows my data" and "no other user can reach it." Closing that gap with a criterion you defined up front is what turns a screen that happens to work into an app you can put real users and real data behind.

There is an honest limit here worth naming. Row-level security protects rows inside a database that supports it, Postgres and Supabase do, and it will not cover every security concern in your app. It does nothing for a leaked API key, an unprotected file upload, or a missing rate limit. It is one criterion, not the whole definition of done. But it is the one that shows up most often and does the most damage when it is missing, which is exactly why it deserves to be written down first.

#What this means if you are building right now

If you are building on Supabase or Postgres this week, the practical step is narrow and immediate. For every table that holds user data, answer one question before you ship the feature that touches it: who owns each row, and is anyone else able to read it? Then turn that answer into a criterion, "each user sees only their own rows, enforced at the database," and verify it the only way that counts: log in as one user, try to reach another user's data directly, and confirm you get nothing back. If you can reach it, RLS is missing or wrong, and you found out before your users did.

The uncomfortable truth underneath this is that the agent is not making your access-control decisions badly. It is making them silently, in favor of whatever demos fastest, every time you leave them out of the spec. A missing row-level security policy is not a mysterious bug in code you cannot read. It is a sentence you did not write. Write the sentence, make it a criterion, and check it. For the fuller version of this discipline, we wrote about how a missing auth guard is a missing acceptance criterion, and about how to write acceptance criteria an AI agent can actually verify.

#FAQ

#What is meant by row-level security?

Row-level security (RLS) is a database feature that restricts which rows a user can see or change based on who they are. Instead of granting access to an entire table, the database attaches a policy to the table and filters rows on every query, so a user only ever sees the rows they are authorized to access. On Postgres and Supabase, a common policy is "a user can only read rows where the user_id column matches their own logged-in ID." The database enforces it automatically, below your app code, so it holds even if a request bypasses your application entirely.

#What is an example of row-level security?

A SaaS app with a shared orders table is the classic example. Every order row has a user_id. Without RLS, any logged-in user who can query the table can read every order, including other customers'. With an RLS policy that says "return only rows where user_id equals the current user," the database automatically filters each query so each customer sees only their own orders, even though all the data lives in one physical table. The same pattern secures multi-tenant apps where many companies share one database but must never see each other's data.

#Should I enable row-level security?

For any table holding user-specific or tenant-specific data, yes. Without RLS, you are trusting every layer of your app, including future code an AI agent writes, to remember to filter by user on every query, forever. One missed filter leaks everyone's data. RLS enforces the rule once, at the database, so it cannot be forgotten higher up. On Supabase, new tables have RLS enabled by default with no policies, which denies all access until you write one, so the real task is writing correct policies rather than turning the feature on. The main trade-off is a small query-performance cost and the effort of writing policies, which is minor next to the cost of a data leak.

#What is the difference between row-level security and column-level security?

Row-level security controls which rows a user can see, filtering the table horizontally so a user gets only the records that belong to them. Column-level security controls which columns a user can see, hiding specific fields, like masking a salary or a Social Security number, so a user can see a row but not every field in it. They solve different problems and are often used together: RLS ensures a customer only sees their own orders, while column-level security might hide an internal cost field on the orders they can see. For most vibe-coded apps, missing row-level security is the more common and more dangerous gap.

#How do I add row-level security to my Supabase app without being a database expert?

Treat it as a behavior to specify and verify, not SQL to master. In plain language, decide who owns each row and who may read it, then make that a written acceptance criterion: "each user sees only their own rows in this table, enforced at the database with row-level security." Hand that criterion to your coding agent as part of the feature, and it can generate the policy. You then verify it without reading the SQL: log in as one user, attempt to fetch another user's data directly, and confirm you get zero rows. You do not need to be a Postgres expert to define the rule and test the result, which is the part that actually protects your users.

BrainGrid is the AI Product Planner that turns your idea into a requirement with acceptance criteria, including the access rules like row-level security, so your agent builds toward a standard instead of guessing. Try it at braingrid.ai.

About the Author

The BrainGrid team building tools to help developers ship better software with AI.

Want to discuss AI coding workflows or share your experiences? Find me on X or connect on LinkedIn.

Get Started

Ready to build without the back-and-forth?

Turn messy thoughts into engineering-grade prompts that coding agents can nail the first time.

Describe what you want to build