TL;DR: Row-level security looked correct on paper and was bypassable in practice, because the client held the key the policy trusted. I explain the trust chain, how I proved the bypass, and where authorisation must actually live.
The scenario
I ran this as a black-box review: no credentials, only the publicly visible surface. Here is what I saw before I touched anything.
An internal platform tool built with a generative application framework on top of a managed relational backend, fronted by a content delivery network. The assessment examined the authorisation model, the client-to-backend trust chain and the exposure of administrative endpoints.
I removed all identifying information. The target is described only by sector and technology class so the pattern can be reused.
The pattern
The pattern I recognised — and that I keep finding in similar estates:
Row level security moves authorisation from the application into the database. Instead of trusting every query your backend sends, the database checks each row against a session variable or claim and refuses rows the caller may not see. It is genuinely strong when it is applied to every table and every role.
The failure mode is almost never a mistake in the policy text. It is a mistake in who holds the context. If the caller can influence the session variable the policy matches on, the policy evaluates correctly against an attacker-chosen tenant. The database is doing exactly what it was told. It is the identity of the tenant that was never verified, and it is verified somewhere else entirely.
Platform-generated applications make this predictable. The framework emits a permissive default key, ships it inside the client bundle, and wires the policy to trust it. From the outside it looks like a properly secured backend because the table policies exist and appear correct. Nothing in the schema reveals that the tenant context is client-supplied.
Findings
| Severity | Finding | Evidence |
|---|---|---|
| CRITICAL | Tenant context is client supplied and trusted by the policy | Any authenticated user could read another tenant rows |
| HIGH | Administrative endpoints reachable after authentication only | No role separation between read and write paths |
| MEDIUM | Service role key present in the client bundle | Bundled key readable by every visitor |
| MEDIUM | Schema introspection left open | Table and column inventory disclosed |
| LOW | Cross origin policy too permissive | Wildcard origin on an authenticated endpoint |
| INFO | Backend correctly isolated from direct network access | Only the edge endpoint reachable |
The attack path
Reproduction in my lab
Every command below targets a lab container I control. Nothing here is aimed at a live system.
Prove the boundary holds, using a lab with two tenants
# target: a lab database with the same policy shape
# tenant A owns the rows, tenant B is the attacker session
# 1. baseline: read only what the policy allows
$ curl -s http://localhost:54321/rest/v1/records?tenant_id=tenant-a -H 'apikey: <anon-key>' | jq 'length'
# 2. the test: swap only the tenant identifier, keep the same session
$ curl -s 'http://localhost:54321/rest/v1/records?tenant_id=tenant-b' -H 'apikey: <anon-key>' | jq 'length'
# rows returned for tenant-b mean the policy trusted the request parameter
# if length is 0 for both, the policy is holding
Detection in your own estate
Compare the tenant seen by the client with the tenant in the row
# never do this against a third party; do it against your own staging data
# 1. insert a canary row you own into your own tenant
# 2. request the collection with a different tenant identifier
# 3. if the canary comes back, the policy is matching on caller input
# inventory the exposure of your own backend first
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:54321/rest/v1/
Check whether the trust anchor lives in the bundle
# search your own built assets for embedded keys or service credentials
$ grep -rInE 'anon.?key|service_role|apikey' dist/ assets/ public/ | head
Remediation
Derive the tenant from the session, never from the request
-- the policy must read the tenant from a claim the client cannot set directly
create policy tenant_isolation on records
using (
tenant_id = nullif(current_setting('request.jwt.claim.tenant', true), '')::uuid
);
-- and revoke the ability to write that setting from the anonymous role
revoke set on parameter request.jwt.claim.tenant from anon;
-- as a belt and braces measure, bind the table owner to a role that is not
-- used by any request path
Once an attacker gains access, they can perform the following actions:
Takeaways
- Row level security is only as strong as the identity behind the context it matches on.
- Test the policy with the caller-chosen value, not with the value you expect.
- A bundled service role key is a full bypass waiting for the wrong reader.
- The database cannot tell you the tenant is wrong if you never tell it who the caller is.