Data exposures in small apps are rarely clever. The page looks right, the login works, and every record is readable by anyone who changes a number in a URL.
The idea
Two questions get confused. Is this caller logged in, and may this caller see this row. Signing in
was a feature you built and tested, so the first is almost always answered. The second is the one
that leaks. Access control is the server deciding, per request, whether this particular caller may
see this particular row. It belongs on the server because everything in front of the server is
under the visitor's control. A hidden button hides a feature from someone not looking for it. It
does not stop anyone calling the
API endpointOne address on your server that accepts requests, such as
/api/orders/42. The browser calls it; anyone else can call it too. directly.
request path where the check must be
browser --> /api/orders/42 --> [ your server ] --> [ database ]
| | |
user controls check here and here, as
all of this (who is asking?) a second line
|
devtools, a script, another logged-in user: same requestHow it works
- No check on the route.
/api/orders/42returns order 42 to anyone who asks. The app only links to your own orders, so it looks fine in use. Changing 42 to 43 returns someone else's address and total. This bug, broken access control, is the first entry in the OWASP Top Ten. - The frontend enforces the permission. The admin panel checks
user.role === "admin"before rendering the delete button, and the delete route trusts that the button exists. The check ran on the attacker's computer, where it can be edited or skipped. - The database is readable directly, with your app not involved. Supabase serves your tables
over its own HTTP API, using a publishable key that ships in your pages by design.
Row Level SecurityA Postgres feature that attaches rules to a table
deciding which rows a given caller may read or write. With it off, anyone who can reach the table
can read all of it. is the only thing standing there. RLS off means a public dataset; RLS
on with a policy of
using (true)means the same public dataset with a policy on top. - User input goes straight into a query. Building SQL by pasting a value into a string lets the value change the meaning of the query, which is Sending input the database reads as commands rather than as data, so a search box can return or delete rows it was never meant to touch.. A parameterised query sends the value separately, which is what the Supabase client and Prisma do for you.
- The response returns more than the page needs.
select *on a users table sends the password hash, the email and the Stripe customer id to a page that displays a name.
What to do
Test as two different callers. They catch different bugs, so do both.
- Get the list first. Ask your AI assistant, in a fresh session: "List every API route that
reads or writes a table. For each, show me the line verifying the caller owns that row, every
place SQL is built by joining strings, and every query selecting
*from a table about people." - As a stranger with no account, query the database directly. This is the test for mistake 3
and your app plays no part in it. Take the project URL and publishable key from your own
.envand run:curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*" -H "apikey: YOUR_PUBLISHABLE_KEY". Rows coming back mean that table is readable by anyone on the internet, since that key is already in your pages. A permission error is the pass. An empty list is not, because an empty table looks exactly like a locked one: put a row in first. Repeat per table. - As a second signed-in user, ask for the first one's data. This is the test for mistake 1. Make
two accounts, and in a second browser profile as B open something belonging to A:
/orders/42, then/api/orders/42. Seeing A's record is the failure. If your app authenticates with a token rather than a cookie, the address bar returns401whatever your code does, so run that half from the browser's network tab. - Read your RLS policies, do not count them. In the Supabase dashboard they sit under Database,
then Policies, and on each table in the table editor. RLS enabled with no policies denies
everyone, which is safe. Do not "fix" that by adding a permissive policy. A real policy has a
usingexpression naming the caller, such asauth.uid() = user_id. Step 2 proves which kind you wrote. - Keep your secret key off the browser. Supabase gives you a publishable key for your pages and
a secret key (
sb_secret_, namedservice_roleon older projects) that bypasses every policy above, because it is what your own server uses. Rungit grep -n "sb_secret_\|service_role"and confirm every match is in server-side code.
Where it breaks
Access control written per route drifts, because the fiftieth route is written months after the first and by a session that never saw the pattern. Pushing the rule into the database with RLS fixes the drift and creates a new failure: a policy that is subtly too permissive is much harder to notice than a missing check, since everything still works. And none of this constrains the secret key or a database connection string, which act as the owner and skip every rule above. Those are secrets rather than permissions, and How secrets leak handles them.