Safe text-to-SQL: let an LLM query your database without writing your SQL
Text-to-SQL means letting a user ask a question in plain language and getting a database query back. Large language models made it easy to build, and just as easy to build unsafely. This page explains where the risks come from, which common defenses fall short, and a structural approach that removes most of the attack surface instead of trying to detect it.
Why LLM-written SQL is risky
- Injection through the prompt. The user’s sentence is untrusted input that steers the model. A crafted request can push the model toward extra clauses, other tables or statements the application never intended. This is often called prompt-to-SQL injection.
- Invented structure. Models produce plausible columns and tables that do not exist, or join to a lookalike. The query fails, or worse, runs against the wrong field.
- Cross-tenant reads. If the model decides which rows to filter, a request such as “show me everyone’s orders” can succeed in a shared table.
- Writes and destructive statements. A model that can emit any SQL can emit
UPDATEorDROP. - Sensitive columns. A model that can select anything can return fields the user should never see.
Common defenses, and where they fall short
- A read-only database role. Necessary, and you should use one. It stops writes, but it does not stop one tenant reading another’s rows or a user reading a sensitive column.
- Parsing or allow-listing the generated SQL. Useful, but you are checking text that an attacker influenced. Every edge case in the SQL dialect becomes your problem.
- Prompt instructions and guard models. Helpful as a layer, but probabilistic. They lower the chance of a bad query; they do not rule it out.
These are worth having. The problem is that each one tries to catch a bad query after the model has written it.
A structural approach: the model fills in a plan, code writes the query
QueryForge changes who writes the query. The model never produces SQL. It fills in a typed query structure (an abstract syntax tree) limited to the fields and operators that your configuration registers. After that it is ordinary code:
natural language ↓ AI planner (model + your config) typed query plan, validated ↓ generators (plain code: no AI, no network) SQL / MongoDB / Elasticsearch, parameterized
For example, the request “cancelled orders over $200 from the last 30 days, newest first” compiles to a parameterized statement, with the values passed separately:
SELECT status, created_at, amount, customer_name FROM orders WHERE (status = $1 AND amount > $2 AND created_at >= $3) ORDER BY created_at DESC LIMIT 50 args: ["CANCELLED", 200, <now − 30 days>]
What the compiler guarantees
- No model-written SQL. There is no query string from the model to inject into.
- Bound parameters. User-supplied values become
$1,$2. Only identifiers from your config reach the statement. - Invented fields are rejected. A field that is not in the config fails validation before anything compiles, with suggestions for the closest valid name.
- Writes cannot be expressed. The plan has no mutation node, so every output is a
SELECTor afind. Read-only does not rely on a check someone might forget. - Hidden fields stay hidden. A field marked as not returnable is excluded from the default projection as well as from explicit selects.
- Impossible requests are refused. The result is a typed refusal, not a plausible query built on a lookalike field.
Tenant scope belongs to your application
In a multi-tenant product, the filter that limits a user to their own rows should not be a decision the model makes. In QueryForge, your application supplies the scope (a tenant ID, a user ID) and the filter is AND-ed onto the query after the model answers, so the model never learns that the field exists. With policy.requiredScope set, a query that arrives without the tenant filter is refused instead of running unscoped.
What this does not do
- It does not replace database permissions. Keep using a read-only role, and the least privilege you can.
- It cannot make your scope values trustworthy. Take tenant and user IDs from your authenticated session, never from user input.
- It does not check that an answer is what the user meant. A valid, safe query can still be the wrong question, so QueryForge returns the validated plan and a plain-language explanation of it, which you can show the user before running anything.
- The user’s sentence and the field vocabulary from your config are sent to the model provider you choose. If that matters, use a local model such as Ollama or a provider you already trust.
Try it
You can describe your data in a configuration file (the config builder makes one in your browser), then call it from Go, Python or Java. The documentation explains every key, and the source is on GitHub under the Apache-2.0 license.