Ground Truth

A Prompt Is Not a Lock: Three Ways Row-Level Security Leaks Even When the Rule Is Written Correctly

Mostafa DhouibMostafa Dhouib·9 min read
The short answer

When an assistant answers questions about live records, the engineering is the permission boundary, not the retrieval. In a Postgres demonstrator with a correctly written row-level security policy, 3 of 8 permission checks still returned another person's full record. All three leaks came from below the policy: an owner-login exemption, a view running as its definer, and a session variable surviving in a pooled connection.

A Prompt Is Not a Lock: Three Ways Row-Level Security Leaks Even When the Rule Is Written Correctly

The short answer. If your assistant answers questions about live records rather than documents, you do not have a retrieval project, you have a permissions project with a language model attached. Instructing the model to only show a parent their own child's data is not a control, because the model is the thing being asked and anything you write in a prompt is a request. The control has to sit in the database. That is necessary and still not sufficient: in a Postgres demonstrator with a correct row-level security policy written, 3 of 8 permission checks returned another child's full attendance record. Every one of the three leaks came from below the policy rather than from the policy being wrong.

There is a specific project that keeps getting scoped as retrieval and is not.

Somebody has a system of record, often an ERP or an internal platform, and they want a natural-language assistant on top of it. They write the brief as a retrieval project because that is the shape of the thing they have read about. Then you look at the example questions they supplied, and most of them are live-record queries: what was my child's attendance this week, how many leave days do I have left, what is the balance on this account.

Those are not document questions. There is no corpus to embed. Every one of them is a database query with a permission boundary attached, and the boundary is the entire engineering problem.

Why the prompt cannot be the boundary

The appealing shortcut is to put the rule in the system prompt. Only answer about children belonging to the authenticated parent. It reads like a rule and it is cheap.

It is not a control, for a reason that has nothing to do with how good the model is. The model is the component being asked to behave. A rule enforced by the thing it constrains is a request, and the attack surface is the entire space of natural language. You cannot test it exhaustively, you cannot prove it holds, and it degrades silently under any input nobody thought of.

OWASP's 2026 LLM Top 10 ranks Excessive Agency third and prescribes the same thing: enforce actions in the user's own context, below the application layer. The model gets to decide what to ask for. It does not get to decide what it is allowed to receive.

So the control moves to the database, and row-level security is the right mechanism. That is where most teams stop, and it is where the interesting part starts.

The system promptthe model is the thing being asked to behave, and the input space is all of natural language
The application layerOWASP ranks Excessive Agency third and prescribes enforcement in the user's own context, below here
The row level policychecked, then rechecked against each failure, and correct in all three cases
Table ownershipthe connection string is usually the role that ran the migrations
View definer rightsa convenience view dissolves the protection with no error and an ordinary query plan
Pooled session statethe next request runs under the previous identity and the answer comes back well formed
3 of 8 permission checks returned another child's complete attendance record. Eight checks is a demonstrator rather than an audit, and the list of leak paths is not complete.
FigureThe control has to sit below the component being constrained, which is why the rule moves out of the prompt and into the database. That is necessary and it is not sufficient, because row level security is evaluated in a context, and three layers below the policy decide what that context is. In the demonstrator all three leaks came from there, with the policy itself correct.

The demonstrator

I built this during a proposal, because a claim about permissions is worth very little without a reproduction.

A Postgres schema with parents, children and attendance records. A row-level security policy written correctly: a parent sees only rows for their own children. Then eight permission checks, each one a plausible path the assistant might take to answer a plausible question.

Three of the eight returned another child's complete attendance record.

The policy was not wrong. I checked it, then rechecked it against the three failures. The rule was correct in all three cases, and the data came out anyway, because row-level security is evaluated in a context, and in each of the three the context was not what I assumed.

Leak one: the owner login is exempt

Row-level security does not apply to the role that owns the table, by default. A table owner, and a superuser, bypasses every policy on it.

This matters because of how applications are usually configured. The connection string in the environment file is very often the role that ran the migrations, which is to say the role that owns the tables. Everything works in development. Every query returns data. The policy is on, it is correct, and it is doing nothing at all, because the role it would apply to is not the role connecting.

The fix is two parts and both are needed. Connect as a least-privileged role that does not own anything, and set FORCE ROW LEVEL SECURITY on the table so that even the owner is subject to the policy. The second part is what turns a configuration mistake into a non-event rather than a breach.

Leak two: the view runs as whoever defined it

A view in Postgres executes with the privileges of its definer, not its caller, unless you say otherwise. So a view over a protected table dissolves the protection: the policy evaluates against the definer, the definer is usually the owner, and the owner is exempt.

This one is particularly good at hiding, because views are how people make a schema pleasant to query. Somebody adds a convenience view so the assistant has a simpler surface to work against, and in doing so routes around the control, with no error, no warning, and a query plan that looks completely ordinary.

security_invoker makes a view run as its caller instead. It is not the default, which means every view you already have is in the other mode unless somebody deliberately changed it.

Leak three: the pooled connection remembers

This is the one that produces the worst story.

The usual way to tell Postgres who the current user is, so the policy can evaluate, is a session variable set at the start of the request. Set the parent identifier, run the query, the policy reads the variable.

Connections are pooled. A connection is handed back to the pool at the end of a request with its session state intact, including that variable. The next request picks up the connection, and if anything at all goes wrong on the path that sets the variable, an early return, an exception, a code path that forgot, the query runs under the previous request's identity.

The assistant answers correctly. For the wrong child. And the correctness is the problem: nothing errors, the response is well-formed, and the parent reading it has no way to know the name on the record is not theirs.

The fix is to scope the setting to the transaction rather than the session, so it cannot outlive the request that set it.

What this means for the project shape

If the assistant answers questions about live records, the first deliverable is not a retrieval pipeline. It is the permission boundary, with the leak paths closed and a test suite that proves each one is closed, because all three of these failures are silent and none of them is caught by testing that the right answer comes back for the right user.

You have to test for the wrong user getting an answer at all. That is a different test, and it is the one that matters. A check that confirms a parent can see their own child's attendance passes in all three leaking configurations above.

The gate
Your permission test asks whether the right parent can see their own child's record. Does it fail in any of the three leaking configurations?
No, it passes in all three
Owner exemption, definer rights view, leaked session variable. The right user gets the right answer in every one of them, so the suite is green while the boundary is open.
Ask for someone else's record instead
Expect zero rows. That is the test that goes red when the policy is bypassed, and it is the one that has to exist before the retrieval work starts.
All three failures are silent. An edtech grading application leaked 18,697 records, including 4,538 student accounts.
FigureThis is the test that separates a working boundary from a broken one, and most suites ask the other question. A check confirming that a parent can see their own child's record passes in all three leaking configurations, because in each of them the policy is right and the data flows anyway. Only the inverse check fails where it should.

The stakes are not abstract. An edtech grading application leaked 18,697 records, including 4,538 student accounts. Permission bugs in systems holding records about children are a category of incident, not a hypothetical.

The honest limit

Eight checks is a demonstrator, not an audit. It was built to find out whether a correctly written policy is sufficient on its own, and the answer was no, three times. It does not enumerate every leak path, and I would not claim the list is complete. There are more, particularly around functions, triggers and extensions that run with elevated privileges.

What the demonstrator establishes is narrower and still useful: writing the rule correctly is the beginning of the work rather than the end of it, and the places it fails are below the rule, where nobody is looking because the rule is right there and it is right.

FAQ

Can I enforce user permissions in the system prompt of an AI assistant? No. The model is the component being constrained, so a rule written in its prompt is a request rather than a control, and the input space you would have to test is all of natural language. OWASP's 2026 LLM Top 10 ranks Excessive Agency third and prescribes enforcing actions in the user's own context below the application. The model decides what to ask for, never what it is allowed to receive.

Why does row-level security not apply when my app queries the database? Most likely your application connects as the role that owns the tables, usually because it is the role that ran the migrations. Table owners are exempt from row-level security policies by default. Connect as a least-privileged role that owns nothing, and set FORCE ROW LEVEL SECURITY so the owner is subject to the policy too.

Why does a view bypass my row-level security policy? Because a Postgres view executes with the privileges of whoever defined it rather than whoever calls it, unless security_invoker is set, and it is not set by default. The definer is usually the table owner, who is exempt, so a convenience view added to simplify querying silently routes around the control with no error.

How can a connection pool leak one user's data to another? If you identify the current user with a session variable set at the start of each request, that variable survives when the connection returns to the pool. Any path that fails to set it, an early return or an exception, runs the next request under the previous user's identity. The answer comes back well-formed and correct for the wrong person. Scope the setting to the transaction instead.

What should I test to catch a permissions leak? Test that the wrong user gets nothing, not that the right user gets something. A check confirming a parent can see their own child's record passes in all three leaking configurations described here, because in each one the policy is written correctly and the data flows anyway. The failing test has to be the one that asks for someone else's record and expects zero rows.

Is an AI assistant over an ERP a retrieval project? Usually not, if the example questions are about live records rather than documents. Attendance this week, leave balance, account status: there is no corpus to embed, only queries with a permission boundary. The retrieval work is small and the boundary work is the project, which is the opposite of how these briefs are normally scoped.

Free worksheet
The Seam Register

The five signs that a failure is in the seams, then one register per boundary covering units, ranges, timing, retry ownership and partial-success behaviour. Filled in separately by both sides, which is the whole method.

One email, the resource, and nothing else unless you reply.
Carrying a program like this one?

Tell us the system, the stakes, and the date that matters. You get a straight technical reply from the person who would lead the work, within 24 hours.

Bring us the program