Lesson 3.7 · 10 min read
Here's a scenario that plays out constantly, and almost nobody sees it coming.
You build something. It works. You're using one of the modern platforms that gives you a database and lets your frontend talk to it directly, which is a genuinely wonderful thing, because you skip writing an entire backend and ship in an afternoon. Your app fetches the current user's tasks and shows them. You test it, log in as yourself, see your tasks, everything is correct. You ship.
And every user in your system can read every other user's tasks. Not because you made a coding mistake. Your code is fine. The bug is in something you never wrote, because nobody told you it existed.
This post is about that gap. It's the last post in the database module, and it's the one that keeps you from causing real harm to real people. The concepts are not complicated. The consequences of not knowing them are severe, and they're getting more severe as the tools get easier and the distance between a beginner and a live production database gets shorter.
For most of software's history, the arrangement was simple. Your database sat behind your backend, and nothing else could reach it. The database had no idea what a "user" of your product was. It only knew about your backend, one trusted client, and the backend decided everything: who's logged in, what they're allowed to see, which rows to fetch. Every security decision happened in backend code, and the database just did what the trusted backend asked.
Modern platforms changed this arrangement, deliberately and usefully. Now your frontend can query the database directly. There's no backend in between. Your React component asks the database for tasks and gets them. This removes an enormous amount of work and is one of the reasons a designer can build a real, working application alone today.
But look carefully at what disappeared. The backend that decided who could see what is gone. And your frontend, as we established firmly in the security post, is not a place where rules can be enforced, because it runs on the user's machine, where they can read it, change it, and bypass it entirely. Your frontend politely asks for "my tasks." Nothing stops someone from asking for "all tasks" instead, or "Sarah's tasks," using nothing but the credentials your app already gave them and a few minutes with the very DevTools you learned to use.
So the security decision didn't disappear. It moved. It has to live somewhere, and the only place left is inside the database itself. That is what this post is about, and it's why "the database is now directly reachable" and "the database must now enforce its own rules" are the same sentence, and why so many people learn the first half and not the second.
The mechanism that solves this is called Row Level Security, usually shortened to RLS, and once you understand what it does, you'll wonder how anything ever worked without it.
Normally, permissions on a database are coarse. A given client can read from the tasks table, or it can't. That's not nearly enough, because every user should read some rows of that table, specifically their own, and none of the others. The permission you need isn't about the table. It's about the rows.
Row Level Security lets you attach rules to a table that decide, for each individual row, whether the person asking is allowed to see it or touch it. The rule is evaluated on every query, for every row, inside the database, before anything is returned. It doesn't matter what the query asked for. Someone can send SELECT * FROM tasks, requesting the entire table, and the database will hand back only the rows their rule permits. Everything else simply isn't there, as far as they're concerned.
This is a profound shift in where trust lives. The rule is not in your frontend, where users can bypass it. It's not in a backend you might forget to check. It's welded to the data itself, enforced by the database, applied identically to every request from every source, forever. You cannot forget to apply it, because it isn't something you apply. It's a property of the table.
Think of it as the difference between a library where the librarian decides which books to bring you, and a library where every book is enchanted so that anyone but its owner sees an empty shelf. In the first, the librarian is a single point of failure and one distracted moment leaks a book. In the second, the protection lives in the books, and there's no distracted moment to exploit.
An RLS rule is called a policy, and it's essentially a condition, written in the SQL you now read, that must be true for a row to be visible or modifiable.
A policy on the tasks table might say, in effect: a row is visible if the row's user_id equals the id of the currently authenticated user. That's it. That's the whole policy, and you can already read the logic in it: it's a WHERE clause, permanently attached to the table, comparing a column to the identity of whoever is asking.
Notice what this requires, and how it ties everything in this module together. The policy compares tasks.user_id to the authenticated user's id. That comparison is only possible because your data model has a user_id foreign key on tasks, pointing at the owner. The relationship you designed back in the modeling post is precisely what makes the security rule expressible. A well-designed data model is a prerequisite for enforceable security, because security rules are questions about ownership, and ownership is expressed through relationships. If your tasks table didn't record who owns each task, no policy could be written, and the row's owner would be a fact nobody could check.
Policies exist separately for different operations, and this separation is where careful thinking pays off. Who may read a row is a different question from who may update it, which is different again from who may delete it. A team task might be readable by every member of the team, editable only by the person it's assigned to, and deletable only by an admin. Three different policies, three different conditions, on the same table. When you sit down to write policies, you are forced to answer, precisely, for each operation: who is allowed to do this, and how does the database know?
That forcing is valuable. Most security bugs are unasked questions. RLS makes you ask them, per table, per operation, before anything ships.
Now the specific mechanic that produces the disaster in the opening scene, and it's worth knowing precisely, because it's the single most common way people get hurt.
On most systems, Row Level Security is off by default. A table you create is, unless you say otherwise, wide open to any client that can reach the database. And if your platform hands your frontend a key to reach the database, "any client" means every user of your app, and also anyone who takes that key out of your frontend, which they can, because it's in code running on their machine.
So the sequence that ruins people is: create a table, build a frontend that queries it, test it while logged in as yourself, see correct results because your app only ever asks for your own rows, and conclude everything is secure. It isn't. Your app's politeness was doing all the work. Nothing was stopping a differently-phrased request.
There's a second trap immediately behind the first. Turning RLS on with no policies written does not open the door partway; it closes it completely. With RLS enabled and no policy granting access, no rows are visible to anyone. Your app breaks. And in that moment, with a broken app and pressure to fix it, people write a policy that permits everything just to get moving again, and never come back. A permissive policy that always evaluates to true is functionally identical to having no security at all, while looking, in a settings panel, exactly like security. That's worse than no policy, because it's invisible.
So the discipline is: enable RLS on every table that holds data belonging to anyone, then write the narrowest policy that makes the app work. Start closed. Open exactly what's needed. Never open everything to unblock yourself, because that unblocking becomes permanent.
Platforms that let the frontend talk to the database give you more than one key, and understanding the difference is not optional.
There's a public key, meant to be shipped in your frontend, deliberately visible to users. It's safe to expose precisely because RLS is enforced against it. Its power is limited entirely by your policies. If your policies are right, this key can do nothing but what any user should be able to do. If your policies are absent, this key can read your entire database, and it's sitting in your app's code where anyone can find it.
Read that twice, because it's the crux. The public key is not safe by nature. It is safe only if your policies make it safe. It is a key whose power you define. Ship it with no policies and you've published a master key.
And then there's a service key, a secret key meant only for your backend, that bypasses RLS entirely. It exists because your server sometimes needs to do administrative things across all rows. It answers to no policy. It sees everything.
That key must never, under any circumstances, appear in frontend code. Not in a component, not in a config file that gets bundled, not in a variable that ships to the browser. If it does, every policy you carefully wrote becomes irrelevant, instantly, for anyone who finds it. This is the exposed-secret failure from the API security post, in its most damaging possible form, because the secret in question is the one that turns off all your defenses.
When you ask an AI to wire up a database and something isn't working, there is a real temptation, sometimes even a suggestion, to use the service key to make the problem go away. It will work. It will also mean your application has no data security whatsoever, and it will look completely fine.
Let's compress this into something you'll actually remember while building.
Your database is directly reachable by anyone holding your public key, and your public key is in your frontend, which means anyone at all. The only thing standing between them and every row of your data is the set of policies you wrote. Not your interface, not your queries, not your intentions. Your policies.
Therefore: every table with data belonging to someone gets RLS enabled and a policy that expresses, precisely, who owns what. Those policies are expressible only because your data model records ownership, which is why good modeling and good security are the same skill viewed from two angles. And no key that bypasses policies ever touches the frontend.
Here's the review that takes two minutes and prevents most of the harm. For each table, ask: is RLS on? If someone sent a query asking for every row in this table, what would come back? Does the policy check ownership, and is ownership actually recorded on the row? Is there any policy here that always returns true? And is any key with bypass powers anywhere near code that ships to a browser?
That's it. Five questions. They are not hard to ask, and asking them puts you far ahead of an enormous number of applications currently running in production with wide-open tables that nobody has ever queried adversarially.
If you have anything built on a platform where your frontend talks to a database, go and look right now, before reading further. Find your tables. Check whether RLS is enabled on each one. If any table holding user data has it off, you have found a real vulnerability in something you may have already shipped.
Then, on any table that does have policies, read them with your new eyes. Does the policy actually compare something on the row to the identity of the person asking? Or does it permit everything? Is there a separate policy for reading versus updating versus deleting, and do they express genuinely different rules, since they usually should?
If you have nothing built yet, do this instead: take the data model you've been sketching through this module and write, in plain English, the policy for each table. "A task is visible only to the user whose id is on the task." "A comment can be deleted only by its author or by an admin of the project." Notice that you cannot write these sentences without knowing exactly who owns what, and notice how the sentences map directly onto the relationships you drew. That mapping is not a coincidence. It's the whole reason the last three posts came in the order they did, and it's the moment where data modeling stops being a structural exercise and reveals itself as the foundation of whether your users' data is actually safe.