Core
- Shows how to make LLM-generated SQL non-executable by enforcing template-only queries with policy gates.
- Provides a production-ready architecture using Open Policy Agent (OPA) for runtime validation and least-privilege enforcement.
- Delivers a CI-testable security model with red-team prompts, canary tables, and policy-as-code checks.
Introduction
Text-to-SQL agents compress a lot of power into a small surface area: a user types natural language, the system generates a query, and the database returns real business data. That’s also why they fail catastrophically when you treat them like “just another UI.” If your agent can be nudged into generating UNION SELECT payloads, bypassing tenant filters, or calling privileged functions, you’ve created a high-bandwidth exfiltration channel.
In 2026, the baseline expectation is no longer “we sanitize inputs.” The baseline is: the model is untrusted, prompts are attacker-controlled, tools are the blast radius, and policy-enforced execution is the last line of defense. We’ll use Open Policy Agent (OPA) as the policy engine to validate and constrain database operations before they run, and we’ll design the agent so that even a compromised prompt can’t directly execute arbitrary SQL.
You’ll leave with an implementable pattern: a tool gateway that only accepts parameterized query templates, an OPA policy that blocks risky SQL shapes, least-privilege database roles, and a test harness with red-team prompts and canary tables.
Hook: The 56,000-star wake-up call
The “56,000-star” incident (as reported in the linked news coverage) is not interesting because it’s a popular repository; it’s interesting because it demonstrates a repeatable failure mode: teams ship agentic database access with the same trust assumptions as a traditional backend, but with a much larger and fuzzier input space.
Traditional SQL injection assumes an attacker can influence a string concatenation boundary. Text-to-SQL agents expand that boundary to: system prompts, user prompts, retrieved context, tool descriptions, error messages, and even “helpful” intermediate reasoning traces if you log them and feed them back.
When an LLM produces SQL, treat the SQL as attacker-controlled input—even if it came from your own model, your own prompt, and your own UI.
That mindset shift drives the rest of the design: don’t “clean” SQL; instead, constrain what can be executed and prove it via tests.
Threat model for agentic SQL
A useful threat model for text-to-SQL agents starts with one assumption: the model can be induced to do the wrong thing. Your job is to make “the wrong thing” non-executable.
1) Prompt injection (tool steering)
Attackers try to override tool instructions (“ignore previous rules”), coerce the agent into revealing schema, or force it to call a tool with malicious parameters. With text-to-SQL, the most common steering is: “run this exact query,” “include hidden tables,” or “remove tenant filters.”
- Direct injection: user asks for a query that includes exfiltration logic.
- Indirect injection: retrieved documents or metadata contain instructions that the model follows.
2) SQL injection (classic payloads, new delivery)
Even if you don’t concatenate user input, the agent might. Many implementations ask the model to “generate SQL,” then execute it verbatim. That’s equivalent to letting the attacker write SQL.
- Stacked queries:
SELECT ...; DROP TABLE ... - Union-based exfiltration:
UNION SELECTfrom sensitive tables - Boolean/time-based probing to infer data
3) Data exfiltration (over-broad reads)
The agent might be “correct” but still unsafe: returning raw PII, secrets, or cross-tenant data because the database role is too powerful or row-level constraints aren’t enforced.
4) Privilege abuse (writes, DDL, admin functions)
Text-to-SQL agents often start as read-only analytics tools, then quietly grow “helpful” capabilities: updating records, deleting rows, running migrations, or calling DB extensions. If the DB role can do it, the agent can eventually be tricked into doing it.
Risk summary: what you must prevent
- Any DDL/DML unless explicitly intended (and then tightly scoped).
- Any access to tables/columns outside an allowlist.
- Any query shape that enables broad extraction (no tenant filter, no LIMIT, unbounded joins).
- Any execution path where the model can submit raw SQL directly.
Guardrails that work in practice
Guardrails fail when they’re purely “prompt-based.” The ones that hold up are enforced at execution time, in code, and ideally in a separate policy layer.
Allowlists: tables, columns, and operations
Start with an explicit contract: which tables and columns the agent is allowed to read, and what operations are allowed (typically SELECT only). This is not optional; it’s the only way to make “least privilege” concrete.
At minimum, your allowlist should be:
- Allowed schemas/tables
- Allowed columns per table (block PII by default)
- Allowed SQL verbs (often
SELECTonly)
Parameterization: never execute model-authored literals
Don’t let the model embed user-controlled literals directly into SQL. Instead, have the model choose a query template and provide parameters separately. Your execution layer prepares statements and binds parameters.
Least privilege: database roles that can’t hurt you
Even perfect query validation can have bypasses. Your database role should be scoped so that bypassing validation still doesn’t grant access to sensitive tables, and cannot write/alter schema.
Query shaping: constrain the “blast radius” of reads
Even safe tables can be exfiltrated at scale. Add mechanical constraints:
- Require
LIMITfor interactive queries. - Require tenant predicates (or enforce RLS in the database).
- Block
UNION,;, comments, and high-risk functions. - Cap execution time and returned rows.
The next step is turning these principles into an enforceable control plane. That’s where OPA fits.
Safer agent architecture: MCP-style tools + policy
Google’s MCP-style direction (toolbox patterns for safer DB querying) aligns with what works operationally: the model shouldn’t “own” execution. Instead, the model calls a narrow tool API, and a gateway enforces policy, parameterization, and auditing.
Reference architecture
Use a three-layer design:
- Agent: produces an intent (which template, which parameters), not raw SQL.
- Tool gateway: validates requests, consults OPA, prepares statements, enforces limits.
- Database: least-privilege role, optional row-level security, audited.
Why Open Policy Agent (OPA) is the right enforcement point
OPA is a CNCF Graduated policy engine that evaluates requests against policies written in Rego. For text-to-SQL, OPA gives you:
- Centralized policy for allowed templates, tables, operations, and constraints.
- Separation of duties: policy changes can be reviewed and versioned independently of agent code.
- Consistent enforcement across services (multiple agents, multiple gateways).
This is the core of how to secure text-to-SQL agents with Open Policy Agent (OPA): treat the agent output as an untrusted request, and require OPA to explicitly allow it.
Deploy OPA (Kubernetes) with a policy bundle
The manifest below runs OPA as a service in Kubernetes and loads policies from a ConfigMap. In production, prefer signed bundles (OCI/HTTP) and CI-controlled promotion, but ConfigMap is a clear starting point.
apiVersion: v1
kind: Namespace
metadata:
name: agent-security
---
apiVersion: v1
kind: ConfigMap
metadata:
name: opa-policy
namespace: agent-security
data:
text2sql.rego: |
package text2sql
default allow = false
# Request shape expected by the gateway:
# {
# "template_id": "orders_by_customer",
# "operation": "select",
# "tables": ["orders"],
# "has_limit": true,
# "limit": 100,
# "tenant_scoped": true
# }
allowed_templates := {
"orders_by_customer",
"orders_by_date_range",
"top_products"
}
deny_reason[msg] {
input.operation != "select"
msg := "only SELECT operations are allowed"
}
deny_reason[msg] {
not allowed_templates[input.template_id]
msg := sprintf("template_id not allowed: %v", [input.template_id])
}
deny_reason[msg] {
some t
t := input.tables[_]
not t in {"orders", "order_items", "products", "customers"}
msg := sprintf("table not allowed: %v", [t])
}
deny_reason[msg] {
not input.has_limit
msg := "LIMIT is required"
}
deny_reason[msg] {
input.limit > 500
msg := "LIMIT too high"
}
deny_reason[msg] {
not input.tenant_scoped
msg := "tenant scope is required"
}
allow {
count(deny_reason) == 0
}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: opa
namespace: agent-security
spec:
replicas: 2
selector:
matchLabels:
app: opa
template:
metadata:
labels:
app: opa
spec:
containers:
- name: opa
image: openpolicyagent/opa:0.63.0
args:
- "run"
- "--server"
- "--addr=0.0.0.0:8181"
- "--log-format=json"
- "/policies"
ports:
- containerPort: 8181
volumeMounts:
- name: policy-vol
mountPath: /policies
volumes:
- name: policy-vol
configMap:
name: opa-policy
---
apiVersion: v1
kind: Service
metadata:
name: opa
namespace: agent-security
spec:
selector:
app: opa
ports:
- name: http
port: 8181
targetPort: 8181
GitHub Repository
OPA Policy Examples for API Gateways
Browse real-world Rego policies and patterns you can adapt for a text-to-SQL tool gateway (allowlists, request validation, and decision logging).
Apply and verify OPA
Use kubectl to deploy OPA and then query it with a sample decision request that represents a tool call your agent wants to make.
set -euo pipefail
kubectl apply -f opa.yaml
kubectl -n agent-security rollout status deploy/opa
# Port-forward for local testing
kubectl -n agent-security port-forward svc/opa 8181:8181 >/tmp/opa-pf.log 2>&1 &
PF_PID=$!
trap 'kill ${PF_PID} || true' EXIT
sleep 1
# Allowed request
curl -s http://localhost:8181/v1/data/text2sql/allow \
-H 'Content-Type: application/json' \
-d '{
"input": {
"template_id": "orders_by_customer",
"operation": "select",
"tables": ["orders"],
"has_limit": true,
"limit": 100,
"tenant_scoped": true
}
}' | jq
# Denied request (no LIMIT)
curl -s http://localhost:8181/v1/data/text2sql \
-H 'Content-Type: application/json' \
-d '{
"input": {
"template_id": "orders_by_customer",
"operation": "select",
"tables": ["orders"],
"has_limit": false,
"limit": 0,
"tenant_scoped": true
}
}' | jq
Tool gateway contract: templates + parameters (not raw SQL)
The key design choice: the agent can only request execution of known templates. The gateway owns the SQL text and uses prepared statements. Below is a minimal Python gateway that (1) maps template IDs to parameterized SQL, (2) asks OPA for an allow/deny decision, and (3) executes safely with bound parameters using PostgreSQL.
This is intentionally small so you can embed it in a service, a sidecar, or a serverless function. In production, add authn/z, rate limits, and structured audit logs.
import os
import json
import requests
import psycopg
OPA_URL = os.getenv("OPA_URL", "http://opa.agent-security.svc.cluster.local:8181")
DATABASE_URL = os.environ["DATABASE_URL"]
TEMPLATES = {
"orders_by_customer": {
"sql": """
SELECT id, customer_id, total_cents, created_at
FROM orders
WHERE tenant_id = %(tenant_id)s AND customer_id = %(customer_id)s
ORDER BY created_at DESC
LIMIT %(limit)s
""".strip(),
"tables": ["orders"],
},
"top_products": {
"sql": """
SELECT p.id, p.name, SUM(oi.quantity) AS units
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.tenant_id = %(tenant_id)s
AND o.created_at >= %(start_ts)s
AND o.created_at tuple[bool, list[str]]:
# Ask for both allow and deny reasons (deny reasons are useful for audit + debugging).
allow_resp = requests.post(
f"{OPA_URL}/v1/data/text2sql/allow",
headers={"Content-Type": "application/json"},
data=json.dumps({"input": input_doc}),
timeout=2,
)
allow_resp.raise_for_status()
allowed = bool(allow_resp.json().get("result", False))
deny_resp = requests.post(
f"{OPA_URL}/v1/data/text2sql/deny_reason",
headers={"Content-Type": "application/json"},
data=json.dumps({"input": input_doc}),
timeout=2,
)
deny_resp.raise_for_status()
reasons = deny_resp.json().get("result", []) or []
return allowed, reasons
def run_tool(template_id: str, params: dict, tenant_id: str) -> list[dict]:
if template_id not in TEMPLATES:
raise ValueError("unknown template_id")
# Force gateway-owned constraints
limit = int(params.get("limit", 100))
limit = max(1, min(limit, 500))
template = TEMPLATES[template_id]
input_doc = {
"template_id": template_id,
"operation": "select",
"tables": template["tables"],
"has_limit": True,
"limit": limit,
"tenant_scoped": True,
}
allowed, reasons = opa_allow(input_doc)
if not allowed:
raise PermissionError(f"OPA denied query: {reasons}")
# Bind parameters (no string concatenation)
bind = dict(params)
bind["tenant_id"] = tenant_id
bind["limit"] = limit
with psycopg.connect(DATABASE_URL, autocommit=True) as conn:
with conn.cursor(row_factory=psycopg.rows.dict_row) as cur:
cur.execute(template["sql"], bind)
return cur.fetchall()
if __name__ == "__main__":
# Example: simulate an agent tool call
rows = run_tool(
template_id="orders_by_customer",
params={"customer_id": "c_123", "limit": 50},
tenant_id="t_001",
)
print(json.dumps(rows, default=str, indent=2))
Notice what the model cannot do here:
- It cannot inject
UNIONor;because it never submits raw SQL. - It cannot access arbitrary tables because templates are pre-registered and OPA enforces allowlists.
- It cannot remove tenant scoping because the gateway injects
tenant_idand OPA requires tenant scope.
Comparison: raw SQL vs template tools vs policy-gated tools
If you’re deciding how far to go, here’s the practical trade-off table.
| Criteria | Execute raw model SQL | Template tools + OPA gate |
|---|---|---|
| SQL injection resistance | Low (model can emit payloads) | High (no raw SQL path) |
| Data exfiltration control | Weak (hard to bound) | Strong (allowlists + limits) |
| Operational auditability | Medium (logs are noisy) | High (structured tool calls + decisions) |
| Developer velocity | High initially | Medium (template catalog + policy) |
| Enterprise compliance fit | Poor | Good (policy-as-code, reviewable) |
Security test plan: prompts, canaries, logs, gates
You don’t “add security” to a text-to-SQL agent; you continuously verify that your constraints still hold as prompts, models, and schemas change. The test plan below is designed to catch regressions before they ship.
1) Red-team prompt suite (automated)
Maintain a versioned set of adversarial prompts that target your threat model: tenant bypass, schema probing, union exfiltration, and instruction override. Run it against the full agent+gateway stack in CI and assert that:
- Forbidden templates are never selected.
- OPA denies unsafe requests with a known reason.
- No query returns rows from canary/sensitive tables.
2) Canary tables (tripwires for exfil)
Create a table that should never be queried by the agent role (e.g., canary_secrets) and alert on any access attempt. This catches both policy mistakes and unexpected DB permissions.
3) Audit logs: tool calls + OPA decisions + DB logs
At minimum, log these as structured events with correlation IDs:
- Agent tool request (template_id, parameters metadata, tenant_id)
- OPA decision (allow/deny + reasons + policy version hash)
- DB execution metadata (duration, rows returned, error class)
Don’t log raw result sets. Be careful with logging parameters that might contain PII.
4) Eval gates: fail builds on unsafe behavior
Make security a release criterion. If any red-team prompt results in a tool request that OPA allows but violates your invariants (no tenant scope, too-high limit, disallowed template), fail the build.
5) A simple CI-friendly policy test
OPA policies are code. Test them like code using the OPA CLI. Below is a minimal policy test file that asserts key invariants (SELECT-only, LIMIT required, template allowlist). Run it in CI on every change.
{
"tests": [
{
"name": "allow known template with limit and tenant scope",
"input": {
"template_id": "orders_by_customer",
"operation": "select",
"tables": ["orders"],
"has_limit": true,
"limit": 100,
"tenant_scoped": true
},
"expect_allow": true
},
{
"name": "deny missing limit",
"input": {
"template_id": "orders_by_customer",
"operation": "select",
"tables": ["orders"],
"has_limit": false,
"limit": 0,
"tenant_scoped": true
},
"expect_allow": false
},
{
"name": "deny non-select",
"input": {
"template_id": "orders_by_customer",
"operation": "delete",
"tables": ["orders"],
"has_limit": true,
"limit": 10,
"tenant_scoped": true
},
"expect_allow": false
}
]
}
In CI, you can translate that JSON into OPA unit tests (Rego test_ rules) or run a small harness that posts inputs to OPA and asserts results. The point is the same: policy changes must be reviewed, tested, and promoted like application code.
Conclusion
Text-to-SQL agents are a direct line to your most sensitive systems, and the “56,000-star” wake-up call is a reminder that popularity doesn’t equal safety. The durable pattern is: the model proposes intent, a tool gateway executes parameterized templates, the database role is least-privilege, and OPA enforces what is and isn’t allowed at runtime.
If you want one actionable next step: implement a template-only tool gateway and put Open Policy Agent (OPA) in front of execution. Then add a red-team prompt suite and a canary table, and make those checks a hard release gate. That’s how you actually secure text-to-SQL agents with Open Policy Agent (OPA)—not by hoping the prompt stays perfect, but by making unsafe behavior non-executable.

