Stop SQL Injection in 2026: Text-to-SQL + OPA

Stop SQL Injection in 2026: Text-to-SQL + OPA

 

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 SELECT from 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 SELECT only)

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 LIMIT for 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.
Architecture diagram showing a text-to-SQL agent using an MCP-style tool gateway with Open Policy Agent enforcing query policies before database execution
Architecture diagram showing a text-to-SQL agent using an MCP-style tool gateway with Open Policy Agent enforcing query policies before database execution

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).

Explore on GitHub →

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 UNION or ; 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_id and 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.

Authors

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *