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 directly.

code
  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 request

How it works

  1. No check on the route. /api/orders/42 returns 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.
  2. 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.
  3. 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. 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.
  4. 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 . A parameterised query sends the value separately, which is what the Supabase client and Prisma do for you.
  5. 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.

  1. 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."
  2. 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 .env and 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.
  3. 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 returns 401 whatever your code does, so run that half from the browser's network tab.
  4. 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 using expression naming the caller, such as auth.uid() = user_id. Step 2 proves which kind you wrote.
  5. Keep your secret key off the browser. Supabase gives you a publishable key for your pages and a secret key (sb_secret_, named service_role on older projects) that bypasses every policy above, because it is what your own server uses. Run git 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.