Your team adds an assistant to the internal admin panel: someone types a question, an LLM turns it into SQL and the answer shows up in seconds. It works so well that people soon ask it for more than answers: “mark customer 42's March orders as refunded.” The model writes an UPDATE, loses the filter along the way, and this is the statement that reaches the database:
UPDATE orders SET status = 'refunded';
Nobody attacked anything, and the model means no harm. It wrote valid SQL for a request it half understood, and nothing between the model and the database checked the statement before it ran. The scenario is illustrative, but the pattern is documented in public cases.
LangChain's documentation warns about it in its SQL agent tutorial: executing model-generated SQL has inherent risks, so scope the connection's permissions as narrowly as you can and add your own validation before that SQL runs (LangChain, “Build a SQL agent”). This article is about that validation: what it has to stop, which guardrails teams use today and where each one stops, and how to add it where your agent already runs SQL.
Where we stand. We build Vericto, a SQL firewall that checks every statement before it reaches the database. So we compare approaches, not vendors, and we also say what a SQL layer leaves out.
Four ways text-to-SQL goes wrong
1. The ambiguous request. “Delete the test orders,” “update the price,” “clean up old sessions.” The model translates the intent and sometimes drops the condition: a DELETE or an UPDATE with no WHERE, or with a WHERE 1=1, which is the same thing. If the tool can write, every row in the table changes.
2. The injected instruction. The text the model reads is not always written by your user: it can come in the question, in a ticket or in a database row. An instruction hidden there can turn a query into … OR 1=1, a DROP TABLE or a read of a table nobody asked for. This is not theoretical: LangChain's SQLDatabaseChain (CVE-2023-36189) and LlamaIndex's text-to-SQL engines (CVE-2024-23751) got CVEs for it, and researchers at INESC-ID characterized these “prompt-to-SQL” attacks on LangChain applications (arXiv 2308.01990).
3. The answer that exposes data. “Which customers complained last week?” To answer, the model reads names, emails and phone numbers, and those values land in its context, in the conversation logs and in the answer shown to whoever asked, whether or not they may see them. No destructive statement, no database permission broken, and it is still a data incident.
4. The unbounded read. A SELECT * with no LIMIT on the orders table works in development and returns the whole table in production: load on the database, a full context window and, again, far more data than the question needed.
The usual guardrails, and where each one stops
None of these approaches is useless. The question is which of the four failures each one stops.
| Guardrail | What it stops | Where it stops |
|---|---|---|
| Prompt instructions (“SELECT only,” “never delete”) | They help the model get it right most of the time | They are a request, not a control: an ambiguous request or an injected instruction can override them. OWASP recommends enforcing authorization in the downstream systems rather than relying on the LLM to decide whether an action is allowed (LLM06:2025, Excessive Agency) |
| A read-only database role | Every write and every DDL statement, if it really is read-only (failure 1) | It does not decide which tables or columns the agent reads within what the role can see, or how many rows (failures 3 and 4). And many useful agents need to write |
| A keyword or regex filter (“must start with SELECT”) | The obvious: a DROP at the start of the text |
It decides on the text, not the structure: a DELETE stacked after a SELECT in the same string or an OR 1=1 get through, and it cannot tell which tables or columns are read |
| A second LLM that reviews the query | Mistakes another model can reason about | It is probabilistic: the same query can get different answers, and the injected text that fooled the first model is read by the second one too |
| A deterministic check on every statement | All four failures: destructive statements, tautologies, sensitive columns and unbounded reads, decided on the statement's syntax tree | It only sees SQL: what the agent runs some other way (Python, an API) is out of its reach. And it protects what goes through it: the full guarantee requires the database to accept connections only through the control |
Among the examples of LLM05:2025 (Improper Output Handling), OWASP also lists LLM-generated SQL queries executed without proper parameterization, leading to SQL injection. The combination that works is defense in depth: a least-privilege role underneath and, on top, a control that checks every statement outside the model and out of the agent's reach. Prompt instructions stay what they are: a way to help the model get it right.
A guardrail the agent can use
A control that only says “no” has a cost: the agent gets stuck, the user gets an opaque error, and someone ends up switching the guardrail off so the feature works again. What helps is a verdict the model can read: which rule the statement broke and how to write the safe version.
This is what a LangChain agent gets back when Vericto blocks the UPDATE from the start. It is not an exception that ends the conversation: it is the SQL tool's result, in text the model understands:
Error: Vericto blocked this query (VERICTO-030, severity critical). It was NOT executed.
Suggested fix: UPDATE orders SET {col} = $1 WHERE id = $2
Rewrite the query following the suggested fix and try again.
The model tries again with the condition it had dropped, and this time the statement goes through:
UPDATE orders SET status = 'refunded'
WHERE customer_id = 42
AND created_at >= '2026-03-01' AND created_at < '2026-04-01';
-- ALLOWED
The same statement always gets the same verdict, because the decision comes from rules on the query's structure, not from a model. That lets you trust the control and, at the same time, explain every decision to whoever has to review it.
How Vericto handles it
Vericto checks every SQL statement before it reaches the database: it parses it into a syntax tree, evaluates it against your workspace's rules and returns a verdict (ALLOWED, FLAGGED or BLOCKED) with the rule, the tree node that triggered it and a suggested fix. For text-to-SQL, that covers the four failures:
- Destructive statements are blocked.
DELETEandUPDATEwith noWHEREor an always-true one (VERICTO-001, 003, 030 and 042),DROP,TRUNCATEand the rest of the 42 standard rules, even when the statement comes after aCOMMIT;in the same string. - Injection tautologies are blocked. An
… OR 1=1at any depth of the query is BLOCKED by VERICTO-090. - Sensitive data comes back masked. In the dashboard you tag the columns that hold personal or payment data with
mask,blockorflag. Withmask, the agent getsa***@example.cominstead of the real email, and the value never enters the model's context (VERICTO-085). - Each agent stays within its allowlist. The tables and columns its task needs, read-only or read-write; everything else is denied with VERICTO-087. An injected instruction that asks for
integration_tokensreads nothing. - Unbounded reads are flagged. A
SELECTwith noLIMIT(VERICTO-050) or aSELECT *with noWHERE(VERICTO-051) run and are flagged, so you can see which agent reads whole tables.
Where your agent already runs SQL
- In the agent's SQL tool: the Python and Node SDKs.
vericto.langchainandvericto.llamaindex(Python 3.10+) and@vericto/sdk/langchain(LangChain.js) replace the framework's SQL database with one that asks Vericto before every execution. A block goes back to the model as the message above, and a masked column runs rewritten. Packages on PyPI and npm. - In any other stack: the Runtime API. Your code sends the query to
POST /api/v1/proxy/querywith one API key per agent and runs it only if the response allows it, usingrewritten_querywhen there is one. On Postgres, MySQL, Oracle, SQL Server, Snowflake and SQLite. - In the connection: the TCP proxy. On Postgres and MySQL you change the host in the connection string and nothing else. It is the inline mode, so what it decides is what the database receives: a block arrives as a native driver error and a masked column arrives masked. If the database accepts connections only from the proxy, the agent has no way around the control.
- While you build it: MCP, the CLI and the GitHub Action. If a coding agent writes your feature's queries or migrations, connect it to the MCP server (
https://mcp.vericto.com/mcp) so it validates the SQL withcheck_sqlbefore proposing it. The SQL that lands in the repository is checked on every pull request withvericto checkor thevericto/check-sql@v1action. - In the dashboard. Every verdict lands in the audit trail with the rule that triggered it. That is where you tag sensitive columns, build allowlists and decide when to move from observing to blocking.
If Vericto does not answer, the SDK does not run the query: it fails closed by default, and with fail_mode="open" it runs it unevaluated and warns you. Parameter values are never sent to Vericto.
Start by observing
A new guardrail on a feature people already use is scary for one reason: blocking something legitimate. That is why a workspace can start in observe mode: Vericto records everything and shows what it would have blocked, without blocking anything. After 7 days protection turns on by itself, or earlier, when you decide. Per-agent allowlists follow the same path: observe, a simulation against the agent's real queries, then enforce.
The Free plan is permanent and needs no credit card: it includes the 42 standard rules, the Runtime API, the SDKs and the MCP server. Sensitive columns and per-agent allowlists are on Team and Enterprise, and Team has a 14-day trial.
Before you ship text-to-SQL
- A least-privilege role. Read-only if the feature only answers questions; never the application's credential or a superuser.
- One identity per agent. Its own database user or API key, so you know who ran what and can give each one its allowlist.
- Every statement checked before it runs, outside the model. On its structure, not its text, so
WHERE 1=1gets the same verdict as noWHEREat all. - Sensitive columns masked or blocked before they reach the model's context.
- Bounded reads. A
LIMITon what the agent reads, and only the tables the feature needs. - A block the model can use. The rule and the suggested fix, so the agent corrects itself instead of getting stuck.
- Every decision recorded, blocks included: what an agent tries after being told no is exactly what you want to see.
Try it on a staging database
- Create a free account and a workspace, and pick observe mode: nothing is blocked while you watch.
- Register the database your feature uses and create an API key for the agent with the
proxy:evaluatescope. - Install the SDK and swap the agent's database:
pip install "vericto[langchain]" export VERICTO_API_KEY=vtro_... # API key with the proxy:evaluate scope export VERICTO_DATABASE_ID=<database-id> # the database you registeredfrom langchain_community.agent_toolkits import SQLDatabaseToolkit, create_sql_agent from vericto.langchain import VerictoSQLDatabase # Was: db = SQLDatabase.from_uri("postgresql+psycopg://app@db/app") db = VerictoSQLDatabase.from_uri("postgresql+psycopg://app@db/app") agent = create_sql_agent(llm, toolkit=SQLDatabaseToolkit(db=db, llm=llm)) - Ask the agent for something ambiguous, such as “mark the orders as refunded,” and look at the verdict in the dashboard: the rule, the tree node and the suggested fix.
- When the log looks clean, turn protection on. On Team, tag the sensitive columns and give the agent its allowlist.
Using LlamaIndex, LangChain.js or another stack? The integration guide has the code for each, and the reference architecture for agents and text-to-SQL shows how to combine the proxy, the Runtime API and MCP.
The goal is not to say no to text-to-SQL. It is to be able to say yes: let the model query real data knowing that every statement goes through a control that does not depend on the model getting it right.