Your RLS Policies Are Perfect and Do Nothing
· Fortus Team · 5 min read
Picture this: you spent a weekend writing careful database access policies — owners can read their own rows, teammates can read shared ones, strangers get nothing. You tested in the dashboard and every rule behaved. Then a reviewer shows you another user's private data loading in the browser, straight through your API, past every policy you wrote. Nothing is wrong with the policies. The problem is that your API routes connect with the admin key — the all-powerful credential that skips every row policy by design — so authorization on those paths depends entirely on whatever each route happens to check, which is usually nothing. This is reportedly one of the most common vulnerabilities in AI-built apps on hosted database platforms, and it is easy to see why: the privileged key "just works" in server code, silencing the permission errors the builder was debugging, and the AI reaches for it because frictionless code is what it optimizes for. Your policies are perfect. Your routes never invoke them.
How the backdoor gets built
Imagine the debugging session where it happens. The builder wires up server routes with the standard user-scoped key, hits permission errors from policies that are still half-written, and asks the AI to make it work. The AI swaps in the admin key, the errors vanish, the demo flows — and the swap ships. From that moment, every route using the privileged client operates above the entire policy system: it can read any row in any table it touches, for any caller, with no policy consulted. The dashboard still shows the policies, the policy tests still pass, and the false safety is total — because the policies were verified on a path the app no longer uses. What makes this especially slippery is that it looks like diligence. The builder did write the policies. The code review shows policy files present and reviewed. But the runtime path that serves real requests runs as the one credential the policies cannot constrain, and nobody checks which key each route holds.
The key-per-route audit
Here is the rule to internalize: request handlers must run under the user-scoped credential with the caller's auth forwarded, so the policy system actually evaluates each query. The privileged key belongs in at most one isolated, audited function performing a genuinely administrative operation — with its own explicit authorization check — never as the default client in request handlers. The fix starts as an inventory: search your server code for every reference to the privileged credential — the admin client factory, the service-role environment variable, any helper with "admin" in its name — and list which routes and server actions use it against user tables. For each one, decide: can this run as the user-scoped client instead, or does it need privilege plus a hand-written ownership check? The common case converts cleanly to the user-scoped client with auth forwarded per request. The rare administrative case gets isolated, commented, and tested as two users to prove cross-user access fails. Document which key each route uses; that list is your authorization map.
Self-test: which key does each route hold?
Tonight, grep your own repo for the privileged credential's name in server code and write down every file that touches it. Open each route handler on that list and answer three questions: does it query user tables, does it run as the privileged client, and does it contain its own explicit ownership check? Any yes-yes-no triple is an open bypass — the route serves whatever rows it queries to whoever calls. Next, prove it live with two test accounts: as account B, request account A's resource through each flagged route and watch whether the policy system intervenes. It will not, because the route never consults it. Then check your environment wiring for how the privileged value reaches server code, and confirm it never leaks toward the client bundle. Finish by converting one route to the user-scoped client with auth forwarded and re-running the two-account test to watch the policies engage. That before-and-after is the whole lesson in miniature — and the template for every remaining route on your list.
What a file-reading scanner flags
A scanner that reads your source files automates the inventory half of your audit: it finds server routes and actions initializing privileged clients, follows the credential from environment references to the handlers that use it, cross-references each privileged route against user-table queries, and flags the ones with no explicit ownership check — citing the client construction and the route as paired evidence. It recognizes the handled state so it stays quiet where things are right: routes on the user-scoped client with auth forwarded, and isolated privileged functions with their own authorization logic. Its honest boundary is deployment-side scoping — secret-manager rules or dashboard key restrictions live outside any repo, so those get noted rather than assumed to cover a code-level finding. Pair the halves the usual way: let file-level checks catch every new privileged-client import your AI generates, and let your two-account test prove what each route actually serves. Policies only protect the paths that run beneath them.
Catch the key that skips your policies
Fortus finds server routes using the privileged database key and flags the ones with no ownership check — with the file and line for each one.
or learn more about Fortus →