RLS was on. The database still handed over the wrong tenant’s data.
Row-level security doesn't know who the customer is until your app tells it, the breach lives in the identity bridge, not the policy.
Apollo Space Research
Apollo Space
A query runs against a table with row-level security switched on, every policy in place, and it returns a row that belongs to a different customer. Nothing is misconfigured. The policy did exactly what it was told. The table was protected, the rule was correct, and the wrong tenant’s data came back anyway.
That sentence sounds impossible if you think security lives in the policy. It is routine once you see where it actually lives.
Row-level security doesn’t know who the customer is until your app tells it, the breach lives in the identity bridge, not the policy.
This post is about that bridge: the thin, easy-to-forget layer between “the database has a rule” and “the database knows which tenant is asking.” It is the layer almost nobody writes a test for, and it is the only layer where a multi-tenant leak can actually happen.
The rule is not the same as the question
Start with what row-level security is, stated plainly, because the name oversells it.
A row-level policy is a filter the database attaches to a table. You write something like a user may only see rows where the row’s tenant matches the current tenant. From then on, every query against that table silently carries the filter. You can write select * from invoices with no where clause at all, and the database quietly appends the rule. It feels like a wall around the data, and people describe it that way: “RLS is on, so tenants are isolated.”
Here is the gap that sentence hides. The policy is a condition. The condition reads a variable, the current tenant. And the database does not magically know the current tenant. It knows whatever the last thing to touch the connection told it.
The policy is half the sentence. Where tenant equals the current tenant, that’s the verb and the object. The subject, who the current tenant is, comes from somewhere else entirely. The policy enforces a comparison; it does not establish either side of it. If the “current tenant” the database believes in is wrong, the policy enforces the wrong comparison perfectly and hands back the wrong rows with a clean conscience.
So the real question was never “is the policy correct.” It’s “where does the database learn who’s asking, and can that answer ever be stale, blank, or someone else’s?”
The naive version: set the tenant, run the query, trust the pool
The obvious way to wire this up is the way it reads in a tutorial. When a request comes in, you figure out which tenant it belongs to, you tell the database “the current tenant is this one,” and then you run your queries. The policy does the rest. Clean.
It works flawlessly in the demo, with one request at a time, on your laptop.
Then it meets a connection pool.
Real applications don’t open a fresh database connection per request, that’s slow, so they keep a pool of warm connections and hand them out. Request A grabs a connection, sets the current tenant to Acme, does its work, and returns the connection to the pool. The “current tenant = Acme” setting is a property of that connection, and nobody cleared it. Request B, belonging to a different customer, grabs the same warm connection a moment later. If B runs even one query before it sets its own tenant, a health check, a lookup, a slightly-too-early read, the policy faithfully filters by Acme. The rule fired. The wall held. The data still crossed.
The policy was never wrong. The connection remembered the last tenant, and nobody asked it to forget.
This is the whole failure mode in one breath: row-level security is enforced per query, but the identity it filters on is set per connection, and connections are reused. The instant those two lifecycles drift apart, you have a leak that no policy review will ever catch, because the policy is flawless. The bug is in the seam between setting the identity and running the query, and that seam is invisible in code review precisely because the dangerous version and the safe version look almost identical.
Why the usual fixes only move the bug
Once a team feels this failure, the first fixes are reasonable and incomplete. Each one narrows the window without closing it.
The first instinct is discipline: always set the tenant first, before any query. This is true and useless in the way “always free your memory” is true and useless. It depends on every code path, present and future, remembering an invisible obligation. The one path that reads before it sets, added six months later by someone who never felt the original bug, reopens the hole, and nothing fails loudly to warn them.
The second instinct is to clear the identity when the connection goes back to the pool. Better. Now a connection can’t carry yesterday’s tenant into tomorrow’s request. But “clear on release” relies on release always running, and release does not always run, a crash, a timeout, an exception on an unusual path, and the connection returns dirty or the cleanup is skipped. You have shrunk the dangerous window, not removed it. The bug now needs a coincidence to fire, which means it fires rarely, which means it fires in production and not in your tests.
The third instinct is the dangerous comfortable one: we ran a test, two tenants, no leak, ship it. The trouble is that the leak needs a race, a specific interleaving of which request grabbed which connection in which order before which set. A passing test proves the leak didn’t happen that time. It cannot prove it can’t happen. Concurrency bugs are not absent because a test was green; they’re hiding until the load and the timing line up.
Every one of these moves the bug. None of them changes the shape of the system that produces it. The shape is the problem: identity and query have separate lifetimes, and as long as they do, someone has to perfectly synchronize them by hand, forever, on every path.
Our way: bind identity to the query, not the connection
The fix is not a better rule or a stricter habit. It’s to stop letting identity outlive the query that needs it. The breach lives in the identity bridge, not the policy, so we rebuild the bridge instead of polishing the rule.
The key idea is simple: the tenant identity should be born with the query and die with it, so there is no window where a connection holds an identity that no current query asked for. Instead of “set the tenant on the connection, then run a query, and hope nothing reused that connection,” you scope the identity to a single unit of work that the database tears down when the work ends, automatically, even on the failure paths, because it’s the same mechanism that already rolls back a failed transaction.
Concretely, every piece of work that touches tenant data opens a transaction, sets the current tenant inside that transaction with a setting scoped to it, runs its queries, and commits. When the transaction ends, success or exception, clean exit or crash, the scoped setting is gone with it. There is no “remember to clear” step to forget, because forgetting is not an option the mechanism offers. The connection returns to the pool carrying nothing. The next request that grabs it finds a blank slate and has to declare its own tenant before it can read a single row, inside its own transaction, scoped the same way.
This flips the default. In the naive version, the unsafe state, a connection holding a stale identity, is what happens when you do nothing, and safety requires constant vigilance. In ours, the safe state is what happens when you do nothing, and there is no path that reads tenant data without first, inside the same scope, declaring who it is. The race condition has nowhere to live because the two lifecycles that used to drift apart are now the same lifecycle.
We add one more thing, and it matters more than it looks. The database role the application connects as is not allowed to ignore the policy. It sounds obvious, but the most common silent leak in this whole category is a connection that runs as a high-privilege role for which row-level security is simply off, every policy you carefully wrote is bypassed, not because it failed, but because the caller was exempt from it. So the application’s role is a plain, unprivileged one that the policy applies to with no exceptions, and every tenant-scoped query also carries the tenant filter explicitly in its own text. Belt and braces: the policy enforces isolation, and the query states the same constraint out loud. If one layer is ever misconfigured, the other still holds.
Field note: the failure that proves the boundary
There’s a test that every team running multi-tenant data should run and most never do, because it asks a question the happy-path tests can’t.
The happy path asks: can tenant A read tenant A’s data? Of course it can; that’s the feature. The question that actually tests isolation is the hostile one: while tenant A is connected and authenticated, can it reach a single row of tenant B’s? You write the probe to try, same connection lifecycle, same pool, the works, and you assert that it comes back empty. Not “should be empty.” Empty, proven, as a test that fails loudly the day someone adds the path that reads before it sets.
When that probe is missing, isolation is an assumption. When it’s present and green under real concurrency, isolation is a property you can point at. The difference between those two states is the entire difference between “we believe tenants are isolated” and “we have a test that breaks the moment they aren’t.” This is the failure mode that visits every team running shared infrastructure across customers, and the discipline that kills it is not a smarter policy, it’s an adversarial test that assumes the leak and forces the system to prove it can’t.
The lesson generalizes past databases. Anywhere identity and action have separate lifetimes, a cached auth token, a thread-local user, a request context reused across an async boundary, the same seam opens, and the same fix closes it: bind the identity to the smallest unit of work and let it die there, then write the test that tries to cross the line on purpose.
The turn: isolation is a promise, and promises are kept by people
Set the transactions and the policies aside, and you’re left with the oldest thing in software that handles other people’s data.
A tenant boundary is a promise. When a company puts its customers’ records into your system, it is trusting that its data and the next company’s data will never touch, and that trust is not established by a feature being switched on. It’s established by an engineer who refused to believe the green test, who asked “but what happens on the path that reads before it sets,” who wrote the hostile probe that tries to cross the line so that the system can prove, on every build, that it can’t. Row-level security is a tool. The promise is kept by the person who treats a clean policy as the beginning of the question, not the end of it.
That instinct, to distrust the comfortable green, to go looking for the seam, is the part you can’t install. A database will enforce whatever rule you give it, perfectly, including the wrong one. What it can’t do is care that the rule is asking the right question. The breach lives in the identity bridge, not the policy, and someone has to be paranoid about the bridge.
That’s what we’re building at Apollo Space: an AI-native operating system where many tenants’ work runs side by side and the boundary between them is something we can prove, not something we hope holds. If you’ve ever stared at a correct policy that still let the wrong row through, you already know the real work was never writing the rule, it was refusing to trust it until something tried to break it and failed.
Apollo runs your company's repetitive ops so your team doesn't.
Join the waitlist for early access, founding-user pricing, and a front-row seat as we ship.
Join the waitlistThe hidden tax of parallel agents is a migration diamond
Six agents writing to one schema conflict in the database, not the code, and CI dies at "multiple heads."
EngineeringAn orchestrator that can't survive its own crash isn't one
A crash that erases the orchestrator's reasoning loses the one thing you can't rebuild.
EngineeringPut a deterministic gate in front of your smartest reviewer
The cheapest defect-catch is a dumb script that checks two merged branches still boot before any judgment.