Data exposures in small apps are rarely clever. The usual shape is that the page looks right, the login works, and every record is readable by anyone who changes a number in a URL. The check was never written, or it was written in the one place the user controls.
The idea
Access control means the server deciding, on every request, whether this particular caller may see
this particular row. It has to happen on the server, because everything in front of the server is
under the visitor's control: the browser, the URL, the request headers, and any code you shipped.
A hidden button hides a feature from a person who is not looking for it. It does not stop anyone
from 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
|
curl, devtools, a script: same request, no UI involvedHow it works
- No check on the route.
/api/orders/42returns order 42 to anyone who asks. The app only ever links to your own orders, so it looks fine in use. Changing 42 to 43 returns someone else's address and total. This class of 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 entirely. - The database is readable directly. Supabase exposes your tables over an HTTP API using a publishable key that ships in the browser. Row Level SecurityA Postgres feature that attaches rules to a table deciding which rows a given caller may read or write. With it off, a caller who can reach the table can read all of it. is what makes that safe. A table created without it, or with it enabled but no policies written, is a public dataset the moment anyone reads your bundle.
- 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 that the database reads as commands rather than as data, so a search box can return or delete rows it was never meant to touch.. Parameterised queries and query builders pass the value separately, so it stays data.
- The response returns more than the page needs.
select *on a users table sends the password hash, the email, the Stripe customer id, and the internal notes to a page that displays a name. Nothing on screen shows it. The network tab shows all of it.
What to do
- Pick one route that reads user data and call it with
curlwhile logged out. If it returns the record, the check is missing. Repeat for the three routes that touch the most sensitive table. - Ask your AI assistant, in a fresh session: "List every API route that reads or writes a table, and for each one show me the line that verifies the caller owns that row." An empty answer for a route is the finding.
- In Supabase, open the table editor and confirm RLS is enabled on every table and that each has at least one policy. Enabled with zero policies means nobody gets in, which is safe but usually a sign the feature was never finished.
- Name the fields you return, one by one, instead of selecting everything.
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 down 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. Neither approach helps with your own service-role key, which bypasses RLS by design. If that key leaks, every rule above is skipped.