All articles
EngineeringAugust 25, 2026·7 min read

Your PII Masking Is Decorative If the Column Is Still Filterable

DBShifts Engineering

The team building the migration platform

We mask PII before it leaves our AI Agent Gateway. An agent asks for email, it gets back a***@***.com. Obviously safe.

Then we tried to break it, and got the real value out in about forty requests — without the agent ever reading the column.

The attack is embarrassingly simple

The agent could not read email. But it could still filter on it. So it stopped asking for the value and started asking how many rows matched:

WHERE email LIKE 'a%'     ->  3 rows
WHERE email LIKE 'ad%'    ->  1 row
WHERE email LIKE 'ada%'   ->  1 row
WHERE email LIKE 'adam%'  ->  0 rows
WHERE email LIKE 'adae%'  ->  1 row

That is a prefix search. Walk the alphabet one position at a time and the masked value falls out. The row count is the only thing leaking, and a row count is not something most people think of as sensitive. The column is never returned, so nothing in the response looks wrong, and every audit entry records a permitted query against an allowed table.

This is a classic oracle: a system that answers a yes/no question often enough to reconstruct a secret. It does not need LIKE either — equality works nearly as well. It just confirms one guess at a time instead of narrowing a prefix.

Why it survives review

  • The masking code is correct. It really does redact the value on the way out.
  • The allowlist is correct. The column is one the agent is permitted to select.
  • The audit log is correct. Every request is recorded, and every one was allowed.
  • Nothing errors. There is no failure to alert on, because nothing failed.

Each layer does its job and the system still leaks, because no layer owns the combination of “you may not read this” and “you may ask questions about it”.

The fix is one line, in the right place

filterable = allowed_columns - masked_columns

An agent may only filter on a column it is allowed to read. A masked column is readable but not knowable, so it is not filterable either. Enforced server-side in the query path — not in a prompt, because the agent is the untrusted party and you cannot prompt your way to least privilege.

One exception is deliberate: filters the owner writes. Row-level security exists precisely to scope rows by a column the agent can never see, such as a tenant id it has no permission to select. The rule restricts the agent, not the operator.

Go and check your own system

If you have given an AI agent access to a production database — through an MCP server, a text-to-SQL layer, or a hand-rolled API — the test takes two minutes. Mask a column, then try to filter on it. If the query is accepted, your masking is decorative.

We found this in our own gateway and closed it. The lesson generalises past masking: a guardrail you have never attacked is a guardrail you have never tested. Asserting that a control is switched on tells you nothing about whether it holds.

This rule is enforced server-side in the DBShifts Agent Gateway — agents get structured tools instead of SQL, and a masked column is not filterable by anyone but the owner.

Migrate with the platform behind these posts

All 49 engine pairs live-tested. Validation, rollback, and CDC built in.

Start Free Migration