Building a Postgres proxy that parses every query: libpg_query and the wire-protocol details nobody tells you

Real libpg_query trees for a DELETE without WHERE, an OR 1=1 and stacked statements, and what a proxy has to get right on the wire: Parse and Bind, the ReadyForQuery status byte, errors 42501 and 25P02, SCRAM channel binding and cancel requests.

13 min read
POSTGRESQL DEEP DIVE

A Postgres role answers one question: may this user touch this object? GRANT DELETE ON orders TO support_agent says the agent may delete rows from orders. It does not say which rows, and it cannot tell DELETE FROM orders WHERE id = 1 from DELETE FROM orders. Row-level security narrows the rows a role can reach, but inside those rows a DELETE without a WHERE still removes all of them.

A person at a psql prompt covers that gap with habit and BEGIN. With an AI agent or a script holding real credentials, nobody reads the statement before it runs. In July 2025 an AI coding agent on Replit deleted a production database during a code freeze (Hacker News discussion). The agent had permission. What was missing was a check on that particular statement.

We build vericto-proxy, a proxy that speaks the Postgres wire protocol, parses every statement and decides whether it reaches the database. This post covers the two things it has to get right: reading a statement exactly the way Postgres reads it, and answering the client in a way drivers can't tell apart from the server. Every tree, verdict and message trace below comes from a real run; the commands are in the appendix.

Where we stand. We build Vericto, and this proxy is ours. What follows describes how it works and also where it falls short.

Part 1: libpg_query, the Postgres parser as a library

libpg_query, from the pganalyze team, takes the parser out of the Postgres source tree and builds it as a standalone C library. You hand it a SQL string; it returns the raw parse tree as JSON or protobuf, the same tree the server's own grammar builds. pganalyze also maintains the bindings, among them the Rust crate pg_query. vericto-engine uses version 6.2 of that crate, which bundles the PostgreSQL 17 parser (the trees below report "version": 170007).

One property matters before anything else: it is the raw parse tree. There is no catalog lookup and no name resolution. The parser knows orders is a relation reference, not whether it is a table or a view.

A DELETE with and without WHERE

The trees below are serialised from the protobuf the Rust crate returns, with locations and default-valued fields removed. DELETE FROM orders:

{
  "stmts": [
    { "stmt": { "node": { "DeleteStmt": {
      "relation": { "inh": true, "relname": "orders", "relpersistence": "p" }
    } } } }
  ],
  "version": 170007
}

DELETE FROM orders WHERE id = 1 has the same node plus a where_clause:

"DeleteStmt": {
  "relation": { "relname": "orders", … },
  "where_clause": { "node": { "AExpr": {
    "kind": 1,
    "name":  [ { "node": { "String": { "sval": "=" } } } ],
    "lexpr": { "node": { "ColumnRef": { "fields": [ { "node": { "String": { "sval": "id" } } } ] } } },
    "rexpr": { "node": { "AConst": { "val": { "Ival": { "ival": 1 } } } } }
  } } }
}

The engine walks this tree into a flat record per statement, then evaluates rules on the record. The rule for a DELETE without WHERE is three lines of Rust:

// vericto-engine, src/rules/evaluator.rs
"VERICTO-001" => find(stmts, |s| {
    s.kind == StatementKind::Delete && s.where_presence == WherePresence::Absent
})

where_presence is Absent when where_clause is missing from the DeleteStmt, AlwaysTrue when the predicate is provably true, and Present otherwise. The walker also records the path it took, and that path goes back to the client with the verdict:

DELETE FROM orders                  → BLOCK  VERICTO-001  DeleteStmt > WhereClause = NULL
DELETE FROM orders WHERE id = 1     → ALLOW

Formatting doesn't move the verdict, because formatting doesn't change the tree. These three strings produce the same DeleteStmt with no where_clause, and the same BLOCK:

delete   from ORDERS
/* WHERE id = 1 */ DELETE FROM orders
DELETE FROM orders -- WHERE id = 1

The reverse holds too. SELECT 'DROP TABLE users' AS note contains the words, but the tree is a SelectStmt whose only target is a string constant. No DROP rule fires; the one finding is VERICTO-050, a Medium-severity flag for a SELECT without LIMIT. A regex that blocks the second example and lets the first one through needs a SQL lexer, and at that point you are writing a parser.

A data-modifying CTE is another case where the first keyword lies. WITH gone AS (DELETE FROM orders RETURNING id) SELECT count(*) FROM gone starts with WITH and ends in a SELECT. The walker descends into the WithClause, finds the DeleteStmt and reports WithClause > DeleteStmt > WhereClause = NULL.

OR 1=1

SELECT id, total FROM orders WHERE user_id = 1 OR 1=1 LIMIT 50 parses to a BoolExpr whose boolop is 2 (OR_EXPR) with two arguments:

"where_clause": { "node": { "BoolExpr": {
  "boolop": 2,
  "args": [
    { "node": { "AExpr": { "kind": 1, "name": [ { "node": { "String": { "sval": "=" } } } ],
      "lexpr": { "node": { "ColumnRef": { "fields": [ { "node": { "String": { "sval": "user_id" } } } ] } } },
      "rexpr": { "node": { "AConst": { "val": { "Ival": { "ival": 1 } } } } } } } },
    { "node": { "AExpr": { "kind": 1, "name": [ { "node": { "String": { "sval": "=" } } } ],
      "lexpr": { "node": { "AConst": { "val": { "Ival": { "ival": 1 } } } } },
      "rexpr": { "node": { "AConst": { "val": { "Ival": { "ival": 1 } } } } } } } }
  ]
} } }

The second argument compares two AConst nodes, so its value doesn't depend on the row and the engine can evaluate it: true. An OR with a provably true argument is true for every row, so the statement gets BLOCK VERICTO-090 at SelectStmt > WhereClause > BoolExpr(OR) > always_true_branch.

What counts as "provably true" is deliberately narrow, and it follows SQL's three-valued logic:

  • Only constant-only predicates qualify. name LIKE '%' and id IS NOT NULL look like tautologies, but they drop rows where the column is NULL, so the engine doesn't treat them as always true.
  • NULL = NULL is unknown, not true. 1 NOT IN (2, NULL) is not a tautology: with a NULL in the list it matches no rows at all.
  • The one column form accepted is a column compared with itself, WHERE id = id. That's the usual way to strip a WHERE while keeping one, and DELETE FROM orders WHERE id = id gets VERICTO-003.

A false positive in a blocking rule is an outage, so the rule only claims what the tree proves.

Stacked statements

SELECT 1; DROP TABLE users is one string, and the parser returns two RawStmt entries:

"stmts": [
  { "stmt": { "node": { "SelectStmt": { "target_list": [ … "AConst": { "val": { "Ival": { "ival": 1 } } } … ], … } } } },
  { "stmt": { "node": { "DropStmt": {
      "remove_type": 42,
      "objects": [ { "node": { "List": { "items": [ { "node": { "String": { "sval": "users" } } } ] } } } ],
      "behavior": 1
  } } } }
]

remove_type 42 is OBJECT_TABLE. Every statement in the list is walked, and the decision is the strongest action of any violation: BLOCK VERICTO-010 on the DropStmt, with the SELECT's flag listed alongside it.

Why the answer doesn't change

The verdict depends on three things: the statement text, the rules and the enforcement policy. Nothing else goes in. There is no model and no sampling, and there are no thresholds. When several rules match, the reported one is chosen by a fixed order (action, then severity, then rule code), so the same rules give the same rule code on every run, whatever order they arrived in. Run DELETE FROM orders -- WHERE id = 1 a thousand times and you get the same tree, the same path and the same rule code each time.

Compare the alternatives on the same inputs. A text filter is fooled by the comment -- WHERE id = 1 and by the words inside 'DROP TABLE users', and a first-keyword check reads the CTE as a SELECT. A second LLM reviewing the query can answer differently on two runs, and it reads the same injected text that misled the first model.

Part 2: the wire protocol

Postgres clients speak the frontend/backend protocol, version 3. After the startup packet, every message is a one-byte type, a four-byte length that includes itself, and a body (message formats). A proxy that reads SQL has to frame both directions exactly. It also has to do something harder: when it answers a message itself instead of forwarding it, the reply has to be what the server would have sent.

Simple and extended query: where the SQL is

In the simple query protocol, one Query message carries the SQL text. The proxy reads it, evaluates it, and either forwards it or answers for the server. These are real traces through the proxy:

client                       proxy                         server
  │ Query "DELETE FROM orders" │                               │
  │ ─────────────────────────► │  BLOCK VERICTO-001            │
  │ ◄───────────────────────── │  ErrorResponse 42501          │  (never sees it)
  │ ◄───────────────────────── │  ReadyForQuery 'I'            │
  │                            │                               │
  │ Query "DELETE … WHERE id=1"│                               │
  │ ─────────────────────────► │  ALLOW ─────────────────────► │
  │ ◄──────────────────────────┼────── CommandComplete DELETE 1│
  │ ◄──────────────────────────┼────── ReadyForQuery 'I'       │

A Query with several statements is refused as a whole. SELECT 1; DROP TABLE users gets one ErrorResponse, and the SELECT 1 doesn't run either. That matches how the server treats the message: multiple statements in one Query run as a single transaction unless the text has its own transaction control.

The extended query protocol, which most drivers use for parameterised queries, splits the work. Parse carries the SQL with $1 placeholders, Bind carries the parameter values, Describe and Execute act on the result, and Sync closes the sequence. The SQL is only in Parse, so that's where the proxy evaluates. Bind, Describe, Execute and Sync carry no SQL and pass through. A named statement prepared once and executed many times is checked once, at its Parse.

When a Parse is blocked, the proxy has to leave the client's state machine where the server would have left it. The protocol docs spell out what the server does on an error in an extended sequence: it "issues ErrorResponse, then reads and discards messages until a Sync is reached, then issues ReadyForQuery". The proxy does the same:

client                                proxy
  │ Parse "DELETE FROM orders"          │  BLOCK VERICTO-001
  │ Bind / Describe / Execute           │  discarded
  │ Sync ─────────────────────────────► │
  │ ◄──────────────────────────────────── ErrorResponse 42501
  │ ◄──────────────────────────────────── ReadyForQuery

Bind values are where the proxy is blind, by construction. This sequence was allowed, because the statement has a WHERE:

-> Parse('DELETE FROM orders WHERE id > $1')  Bind(['0'])  Describe  Execute  Sync
<- ParseComplete, BindComplete, NoData, CommandComplete(DELETE 3), ReadyForQuery(I)

It deleted all three rows in the test table. The tree has id > $1, and $1 is a ParamRef with no value.

Answering for the server without lying about the transaction

Every ReadyForQuery ends with a status byte: I idle, T inside a transaction block, E inside a failed transaction block (ReadyForQuery). Clients rely on it. psql's %x prompt shows it, and libpq's PQtransactionStatus() returns it. When the proxy writes a ReadyForQuery itself, it has to choose that byte, and the server isn't there to tell it.

Here is what the proxy did up to 4.14.3, and still does with its default configuration:

-> Query('BEGIN')                        <- CommandComplete(BEGIN), ReadyForQuery(T)
-> Query('DELETE FROM orders')           <- ErrorResponse(42501 …), ReadyForQuery(I)    ← written by the proxy
-> Query('SELECT count(*) FROM orders')  <- RowDescription, DataRow, CommandComplete(SELECT 1), ReadyForQuery(T)

For one round trip the client was told it was idle while the server held an open transaction. 4.15.0 fixed this. The relay that copies server bytes to the client now also frames them (after startup, every backend message is a tag plus a length, so framing from the first byte is exact) and records the status byte of each ReadyForQuery. Every reply the proxy writes itself carries the status the server reported last:

-> Query('BEGIN')                        <- CommandComplete(BEGIN), ReadyForQuery(T)
-> Query('DELETE FROM orders')           <- ErrorResponse(42501 …), ReadyForQuery(T)
-> Query('SELECT count(*) FROM orders')  <- RowDescription, DataRow, CommandComplete(SELECT 1), ReadyForQuery(T)

T is the right answer, not E. The server never saw the blocked statement, so its transaction isn't aborted, and the next statement runs normally. If the proxy reported E, a correct client would roll back work the server still holds.

The tracking shipped together with the first statements the proxy consumes itself: with business policy rules on (VERICTO_POLICY_RULES, a preview), SET vericto.ticket and SET vericto.tenant give the session context for policies and are never forwarded. The proxy answers them with CommandComplete SET. In a failed transaction block, Postgres refuses every command except the end of the block with SQLSTATE 25P02. If the proxy answered SET there, the client would think the session was healthy, so in state E the proxy answers the way the server would and doesn't take the value:

-> Query('BEGIN')                           <- CommandComplete(BEGIN), ReadyForQuery(T)
-> Query("SET vericto.ticket = 'OPS-1234'") <- CommandComplete(SET), ReadyForQuery(T)          ← proxy
-> Query('SELECT 1/0')                      <- ErrorResponse(22012: division by zero), ReadyForQuery(E)
-> Query("SET vericto.tenant = 'acme'")     <- ErrorResponse(25P02: current transaction is aborted, …), ReadyForQuery(E)   ← proxy
-> Query('ROLLBACK')                        <- CommandComplete(ROLLBACK), ReadyForQuery(I)

One caveat, stated plainly: the tracking is only built when VERICTO_POLICY_RULES is on. With the flag off, the proxy is kept byte-for-byte identical to 4.14.3, so in the default configuration a block inside a transaction block still reports I.

A block is an ordinary SQL error

A blocked statement comes back as an ErrorResponse with SQLSTATE 42501 (insufficient_privilege). The rule code, the AST path and a suggested fix go in the message field, so any client that prints the message shows them:

$ psql "host=proxy port=5433 …" -c "DELETE FROM orders"
ERROR:  Vericto blocked this query [VERICTO-001] — AST node: DeleteStmt > WhereClause = NULL. Suggestion: DELETE FROM orders WHERE id = $1

No driver, ORM or connection pool needs a change. Each of them already turns an ErrorResponse into its usual exception carrying the SQLSTATE, the connection stays open, and an agent's tool call gets an error it can read and correct.

TLS termination and SCRAM channel binding

To read SQL, the proxy has to decrypt it. With PROXY_TLS_MODE=require it terminates the client's TLS with its own certificate and opens a separate TLS session to the database (UPSTREAM_SSLMODE). Authentication messages are relayed unchanged, so scram-sha-256 works and the proxy never holds a password.

SCRAM channel binding (SCRAM-SHA-256-PLUS, tls-server-end-point) exists to defeat exactly this arrangement. The client mixes a hash of the server certificate it saw into its SCRAM proof, and the server checks it against its own certificate (SASL authentication). Behind a TLS-terminating proxy, the client saw the proxy's certificate, so the check fails. That's the feature doing its job, and no proxy that reads SQL can pass it.

What we measured with psql 17.11 against the proxy:

Hops channel_binding=require prefer (the libpq default) disable
client→proxy TLS, proxy→db TLS FATAL: SCRAM channel binding check failed same failure connects
client→proxy TLS, proxy→db plaintext channel binding is required, but server did not offer an authentication method that supports channel binding connects connects

The prefer result in the first row is the surprise. With TLS on both hops, the server offers SCRAM-SHA-256-PLUS, libpq picks it, and the connection fails instead of falling back. Through a TLS-terminating proxy, connect with:

sslmode=require channel_binding=disable

(channel_binding in the libpq docs.) The same reasoning rules out cert authentication tied to the end client: the database sees the proxy.

Cancel requests travel on another connection

Canceling a query doesn't use the session's connection. The client opens a new one and sends a 16-byte CancelRequest with the process ID and secret key it got in BackendKeyData at startup. The proxy relays BackendKeyData unchanged and fronts a single database, so it can forward the packet as it is. Through the plaintext proxy, Ctrl-C in psql gave Cancel request sent and ERROR: canceling statement due to user request, as it does on a direct connection.

With PROXY_TLS_MODE=require it didn't work. psql 17.11 sent its cancel without TLS (the protocol allows an SSLRequest before a CancelRequest, but doesn't require one), the proxy refused the plaintext packet the way it refuses any plaintext startup, and the query ran to completion. We found this while writing this post. If you rely on cancellation, keep it in mind when you turn on client TLS at the proxy.

COPY

The proxy evaluates the COPY statement itself. COPY … TO/FROM PROGRAM and COPY … FROM a file on the database server have their own rules. The rows that follow travel in CopyData messages, which aren't SQL, so they pass through without evaluation.

What it doesn't do

  • It only sees SQL that goes through it. The guarantee holds when the database accepts connections only from the proxy, through pg_hba.conf, network rules or both. A client that reaches port 5432 directly isn't checked.
  • Bind values aren't evaluated. WHERE id > $1 is a WHERE. Whether $1 is 0 is decided by the application.
  • It reads the raw parse tree. There's no catalog. A function call is a FuncCall node, and what the function does inside isn't in the tree. A rule fires only on the node shapes the walker records, which is why each gap found becomes a regression test.
  • Unparseable SQL is forwarded by default. If libpg_query rejects a statement, the proxy forwards it and reports it as VERICTO-PARSE-ERROR. On Postgres the parser is the server's own, so most of these are statements the server would reject anyway. The exception is version skew: CREATE TABLE t (a int, b int GENERATED ALWAYS AS (a * 2) VIRTUAL) is valid on PostgreSQL 18, and the bundled PostgreSQL 17 parser stops at VIRTUAL. Fail-closed is a setting in the workspace policy; with it, these statements are refused with 42501.

The proxy speaks MySQL's protocol too. That has a different set of details and would need its own post.

Next

We measured what this costs in latency; that's the next post.

It's free to self-host with the built-in rules, no account needed: github.com/vericto/vericto-proxy. The proxy and the engine are source-available under the FSL-1.1-ALv2, and each release becomes Apache-2.0 two years after it ships.

Appendix: how the examples were produced

Trees and verdicts. A small Rust program against vericto-engine 4.5.0 (origin/main) and the pg_query crate 6.2.1:

let tree = pg_query::parse(&sql)?;                       // libpg_query, PostgreSQL 17.7 parser
let json = serde_json::to_value(&tree.protobuf)?;        // prune locations and default values
let o = vericto_engine::evaluate(&sql, Dialect::Postgres,
                                 &standard_rules(), &EnforcementPolicy::default());
println!("{:?} {:?} {:?}", o.decision, o.rule_code, o.ast_node_path);

Wire traces. vericto/vericto-proxy:4.15.3 in front of postgres:17, driven by a raw pgwire v3 client that prints each backend message and the ReadyForQuery status. 4.16.0 changes only the signed policy bundles; its CHANGELOG states the per-statement path is untouched.

docker network create pgw-net
docker run -d --name pgw-db --network pgw-net -e POSTGRES_HOST_AUTH_METHOD=trust postgres:17
docker run -d --name pgw-proxy --network pgw-net -p 127.0.0.1:15433:5433 \
  -e UPSTREAM_HOST=pgw-db vericto/vericto-proxy:4.15.3
docker run -d --name pgw-proxy-on --network pgw-net -p 127.0.0.1:15434:5433 \
  -e UPSTREAM_HOST=pgw-db -e VERICTO_POLICY_RULES=observe vericto/vericto-proxy:4.15.3

TLS and channel binding. postgres:17 started with ssl=on, a self-signed certificate and POSTGRES_PASSWORD set (SCRAM), and the proxy with TLS on both hops:

docker run -d --name pgw-proxy-tls --network pgw-net \
  -e UPSTREAM_HOST=pgw-db-tls -e UPSTREAM_SSLMODE=require \
  -e PROXY_TLS_MODE=require -e PROXY_TLS_CERT="$(cat proxy.crt)" -e PROXY_TLS_KEY="$(cat proxy.key)" \
  vericto/vericto-proxy:4.15.3
for cb in require prefer disable; do
  docker run --rm --network pgw-net -e PGPASSWORD=demo postgres:17 psql \
    "host=pgw-proxy-tls port=5433 user=postgres dbname=postgres sslmode=require channel_binding=$cb" \
    -Atc "select 'ok'"
done

Cancel. psql -c "select count(*) from generate_series(1, 2000000000) g" in a container, then docker kill -s INT on it after four seconds.