A cross-tenant leak is rarely a broken policy. It is a query that never reached one.

Row-level security is the isolation control most shared-database products can turn on without re-architecting, and it is enforced by a database that trusts your application about who is asking. That trust is the part you still own.

What row-level security actually is

Row-level security moves the question of which records a user may see out of the application and into the database. A policy is attached to the table and, once row security is enabled on it, every query from a role that is not exempt is constrained by it: the one written at speed on a Friday, the one added by a new hire, and the ones nobody has written yet. The vocabulary here follows PostgreSQL’s implementation, cited as the reference rather than as a recommendation.

The consequence is the entire reason to adopt it, and part two of this handbook already argues the case: enforced in application code, a forgotten tenant filter returns another customer’s data; enforced in the database, it still returns only the caller’s own rows. The failure mode of a mistake becomes a query that quietly did the right thing rather than a disclosure notice.

This article assumes you made that decision and picked the database. The remaining question is narrower and less comfortable. What has the database actually promised, and what is it still trusting somebody else to get right?

Why a silent control stops being reviewed

Database enforcement changes how people behave around it, and that is where the exposure creeps back in. A control that works invisibly gets trusted invisibly. Once policies are on the tables, tenant isolation stops being something anyone revisits, because the answer to every question about it becomes that the database handles it.

That answer is mostly true, and it is exactly specific enough to be dangerous. The database enforces the policy you wrote, against the identity it was handed, for the roles the policy covers. It does not confirm the identity is genuine. It does not apply to every role that can reach the table. It does not close every path by which the contents of a row can be inferred.

Each of those gaps is held shut by something outside the database: your application code, your migration process, your on-call access, the admin tool somebody wrote in an afternoon. They are organizational rather than technical, which means they decay the way organizational things decay, quietly and in the direction of convenience.

What the database checks, and what it takes on faith

The mechanism matters only to the depth that shows you where the guarantee starts, which is later than most people assume.

   the request                      what the database is told
        |                                     |
        v                                     v
   application  ---- sets the tenant ---->  session identity
        |                                     |
        |                                     v
        +-------- runs the query ------>  policy compares each row
                                          against that identity
                                                   |
                                                   v
                                          the rows it permits

Everything to the left of that identity is your application’s word. The database takes it, applies the policy faithfully, and returns a correct answer to the question it was asked. Whether that was the right question is not something it can check.

The database guaranteesThe database assumes
Every query on the table is constrained by the policyThat the tenant identity it was handed is the right one
A forgotten condition still returns only the caller’s rows, never another tenant’sThat the connection is not running as a role that bypasses policies
The rule still holds for code written after everyone who agreed it has leftThat no other path reveals what the policy withheld

The left column is why you adopted it and it is genuinely strong. The right column is your remaining work, and none of it is decided by the policy: it lives in role attributes, table ownership, connection code, and process.

Five ways row-level security still leaks

Each of these produces a working policy, a passing test, and an exposure.

  • The connection is exempt from the policy. Superusers and roles carrying the bypass attribute always read straight through row security, and a table’s owner does too unless it has been explicitly made subject to it. This matters because the role that ran your migrations often owns the tables, and an application may well connect as that same role. When it does, the policies exist, they read correctly, and they never once take effect.
  • The tenant identity is set by the caller. The policy compares each row against whatever identity it was given. A request path that forgets to set it usually gets no rows or an error, which is safe and loud. A pooled connection still carrying the previous request’s value gets the previous tenant’s rows, which is faithful enforcement of the wrong answer. This is the failure that most closely resembles the one row-level security was adopted to prevent, which is what makes it hard to spot.
  • The work that crosses tenants runs around it. Migrations, scheduled jobs, exports, analytics syncs and internal admin tooling connect with privileged roles by design, because their whole purpose is to see every tenant. A role that is exempt from row security is not constrained by any policy, so each of these is a path the policy does not touch, each tends to be written quickly, and together they are rarely reviewed as a set. The choice is to run that work as a role that is subject to policies of its own, or to keep the exempt list short and reviewed.
  • A view hands back what the policy withheld. Access to the tables underneath a view is decided by the permissions of the view’s owner rather than the caller unless the view is explicitly created to invoke the caller’s rights, so a view built by a privileged role can return rows that the caller’s own policy would have hidden. The same applies to functions that run with their creator’s privileges.
  • Constraints disclose the rows they protect. Referential integrity checks deliberately bypass row security, because uniqueness and foreign keys have to hold across the whole table rather than per tenant. A unique constraint on an email address that spans tenants will therefore refuse a signup because a different tenant already registered it, without the caller ever being permitted to see that record. The row stays invisible; its existence does not. A unique constraint that includes the tenant identifier removes that particular collision, which shrinks the exposure to values that genuinely must be unique across every customer. Foreign key checks can still disclose a hidden row, so the exposure shrinks rather than vanishes.

The first four are configuration and process. The fifth is a property of the database doing its job correctly, so there is no setting that removes it. It is a design constraint to know about before someone reports it as a bug.

Questions to ask before trusting row-level security

Put these to whoever built it, and treat “the database handles that” as the beginning of the answer rather than the end.

  • Which database role does the application connect as, does it own the tables, and has it been made subject to policies rather than exempt from them?
  • Where in a request is the tenant identity set, and what happens on a path that forgets? Has anyone deliberately tested a request that skips it?
  • If connections are pooled, what resets the tenant identity between requests, and how would we find out if that stopped working?
  • Which jobs, exports and admin tools connect with a role that bypasses policies, who reviewed that list, and when was it last shortened?
  • Do any views or functions run with their creator’s privileges rather than the caller’s, and does anyone check when new ones are added?
  • Which uniqueness constraints span tenants, and would a rejected signup tell an outsider something about another customer?

Answers to those separate a policy that protects customers from one that protects a diagram. The underlying discipline is the one OWASP describes as denying by default and granting least privilege: row-level security implements it well, and only for the roles you actually made subject to it.

Start here. List every database role your product connects with, from the application to the migration runner to the analytics job, and against each one write down whether it bypasses row-level security and why it needs to. The list is usually shorter than people expect and longer than they are comfortable with, and you will know it is finished when every row has a reason next to it.

A policy in the database is a promise about rows. It was never a promise about who the database was told you are.