Resources · Security · Text-to-SQL

Text-to-SQL prompt injection still has to pass the SQL gate

Text-to-SQL prompt injection is an attack where text the model reads as data, such as a question, a stored column value, or an earlier turn, is written so the model reads it as instructions and emits SQL the application never intended. Chion places controls at three boundaries instead of trying to win that argument inside the prompt.

By Jonathan Dag, Founder & CEO·

See the read-only SQL validator in action →

Prompt injection against a text-to-SQL agent (OWASP LLM01) is not solved inside the prompt. Chion puts controls at three boundaries instead. User-controlled values run through the prompt escaper, capped at 400 characters at the question boundary, before they are interpolated into a prompt. Two code validators then check the generated statement and reject anything that is not a single read-only SELECT. Execution is bounded last, by the PostgreSQL role you supply, a per-request statement_timeout, and a LIMIT wrap at 1,000 rows or 12,000 cells. None of this prevents an injection attempt. It decides what the attempt is allowed to become.

This post walks through how Chion defends the 13-step SQL pipeline against prompt injection, what each layer actually catches, and the failure modes we still consider open work.

These controls apply across Chion's surface: the SQL query generator, the multi-turn conversational analytics layer, and the skills generator that compiles queries saved and reviewed in Studio into CHION.md files for Claude Code and Codex. A skill emitted by the generator carries SQL that already passed the same two validators, so it reaches execution on the same footing as a fresh question rather than on a shortcut around the gates.

User input is data, never instructions: sanitize on every entry into a prompt

Every user-controlled value that gets interpolated into an LLM prompt (the natural-language question, an entity name, a follow-up turn, a keyword filter, a column value rendered for grounding) runs through the shared escape module. It has two variants: a display escape that strips markdown, braces, brackets, and quotes, and a round-trip escape that leaves those characters intact so a value the model echoes back still matches the row in your database. Caps are per call site: 400 characters at the question boundary, which 21 call sites use; 1,000 for a Navigator question, 500 for a prior-state message, 25 or 80 for a sampled column value, and 100 when a caller passes none:

escapePromptDisplay(userInput, { maxLength: 400 })

The display escape does three things. It strips markdown constructs (##, **, __, ---, backticks, {, }, [, ]) and any XML-like tag via regex; these are the substring shapes that LLMs treat as prompt directives or system-prompt boundaries. It truncates at the cap its call site passed, so an attacker cannot bury a directive deep in a long prefix. And it returns a tagged string that the prompt builder always wraps in delimited quotes, so the LLM sees user input as a fenced literal, not as continuation of our instructions.

The sanitization does not persist through stringification. If a sanitized turn gets serialized into conversation history and the history then gets re-interpolated into a future prompt, the protective wrapping is gone. Every prompt builder in the pipeline calls escapePromptDisplay before interpolation, and history turns are re-escaped on every reload. This is a code convention audited per call site, not a type-level guarantee.

Even if injection beats the prompt, the generated SQL still has to pass the read-only gate

Even if a prompt-injection attempt somehow steers the LLM into emitting a destructive query, the two-layer validator (L1 + L2) plus execution-time enforcement (LIMIT wrap + statement_timeout) block execution before the database sees it. Parameterized queries, the classic SQL-injection fix, cannot help here: the LLM draws no boundary between data and instructions, so validation has to happen after generation, on the SQL itself and at the database role.

L1: assertReadOnlySelect. The first gate is a text check, not a Postgres parser, and it is worth being precise about that. It strips dollar-quoted blocks and nested block comments first, so a payload cannot hide inside DO $$ … $$ or a comment. It then rejects a second statement after a semicolon, requires what remains to begin with SELECT or WITH, and sweeps 31 forbidden patterns over the stripped text: INSERT INTO, UPDATE, DELETE FROM, DROP, TRUNCATE TABLE, ALTER, GRANT, CREATE, CALL, COPY, SELECT … INTO, side-effect functions such as pg_read_file and dblink_exec, and the MySQL and MSSQL file-access forms. A write-side CTE (WITH … INSERT) fails on the INSERT INTO pattern rather than on a grammar rule. Pure read-only set operations like UNION ALL SELECT are correctly allowed; we've had to remind ourselves on review that not every SELECT-shaped surface is a write.

L2: validateQuery. The second gate is a separate implementation that runs its own forbidden-statement regex, its own statement-prefix check, and its own multi-statement guard after L1 has cleared. This is deliberately belt-and-suspenders: a gap in one pattern list is not a gap in both. The duplication caught a real bug once. A CTE interpolation introduced a trailing semicolon that broke L1's single-statement contract, and the fix was wrapWithBase(), a CTE wrapper that strips trailing semicolons and handles WITH / WITH RECURSIVE / leading comments / duplicate-CTE guards before concatenation.

L3: statement_timeout + LIMIT wrap. The third gate is the Postgres connection itself. Every query runs with statement_timeout set per request, against a read-only role with no DDL privileges, with a LIMIT wrapped around the outermost SELECT that caps results at 1,000 rows or 12,000 cells (whichever hits first). Even if both software gates failed, the database refuses to execute long-running or unbounded queries.

Multi-turn is where injection hides. We re-escape the whole history every turn

Multi-turn conversations are where most production prompt-injection attacks live. A user can put a benign question in turn 1, an injection payload in turn 2, and rely on the LLM's tendency to weight recent context heavily. Our discovery layer reloads the entire conversation history on every turn, and the sanitization re-runs from scratch on every reload. There is no cached "already-safe" history. The cost is a few extra milliseconds per request; the benefit is that no malicious payload survives a turn boundary.

Chion's conversational analytics surface applies this on every multi-turn exchange. The fact that the user has been chatting cleanly for ten turns earns them no escape from sanitization on turn eleven.

What this does not catch

A few attack classes survive the layers above and we want to be honest about them.

Schema-shape exfiltration. An attacker who can ask plain-English questions can probe the schema: "what tables have an email column?", "list every table whose name contains 'audit'". This is by design: natural-language schema exploration is a feature, not a bug. The mitigation is enforcing per-user RLS at the database role level, which the validator pipeline cannot bypass.

Column-name hallucination. An attacker who knows your schema can craft a question that nudges the LLM to reference a column that exists but the attacker shouldn't see. Our defense is the typed SQL contract: phase 08 binds columns, roles (x/y/y2/series), aggregation, and grain to a typed shape before SQL generation in phase 09. Columns outside the contract reject at compile time. But the contract is informed by the LLM, which means a sufficiently clever question can broaden the contract's column set. RLS is again the floor: a column outside the user's row-level scope rejects at execution regardless of what the contract permits.

Cell-value leakage through narration. Phase 12 produces a grounded narrative summary, which is the only place LLM output sees actual cell values. We escape every value before interpolation, but the LLM still sees them. For workloads where individual cells are sensitive (PII, health, regulated finance), the narrative phase should be disabled and only the chart + SQL surfaced. We're working on a per-customer toggle.

Your last line of defense is the database role, not the prompt

Treat every user-controlled string as data, sanitize on every entry into a prompt (including history re-injection), validate generated SQL with the two-layer validator (L1 + L2) before execution, and run against a database role with RLS and statement timeouts. None of these is sufficient alone; all of them together constitute the production posture.

The complete security model (vault-encrypted credentials, audit logging, the trust boundaries between LLM and database) is documented on the Chion trust center. The pipeline architecture, including all 13 steps, is on the How it works page, and the SQL agent primer covers the read-only agent model end-to-end.

Read-only SELECT. Credentials in an AES-256-GCM vault. Results capped at 1,000 rows. Each executed query leaves an audit row, written fire-and-forget so a logging failure never blocks your answer. Read the full security model →

See how this posture compares to text-to-SQL tools.

Written by

Jonathan Dag

Founder & CEO, Chion