All posts
VibeZero Team5 min read

SQL injection, explained

SQL injection is 27 years old and still in the OWASP Top 10. Why AI-generated code reintroduces it, the one fix that actually works, and how to test for it.

  • security
  • vibe coding
  • engineering

SQL injection was first written up publicly in 1998. It has a fix that is one line, universally available, and faster than the vulnerable version. It is still in the OWASP Top 10, and it still shows up in code an AI wrote yesterday.

That combination is worth explaining, because the reason it survives is not that the fix is hard. It is that the vulnerable version reads more naturally, and a model trained on decades of tutorials has seen the natural-looking one far more often than the correct one.

The mechanism

Your database does not receive a query and some data. It receives a string, and it parses that string into a query. If a value the user controls ends up inside the string before parsing, the user is not supplying data, they are supplying grammar.

# Vulnerable. The user is writing SQL, not filling in a blank.
cursor.execute(f"SELECT * FROM users WHERE email = '{email}'")

Supply ' OR '1'='1 as the email and the database parses:

SELECT * FROM users WHERE email = '' OR '1'='1'

The quote closed the literal early, and everything after it became part of the query. There is no bug in the database here. It did exactly what the string said. The bug is that the application let a value become syntax.

Once that boundary is gone, the ceiling is whatever the connecting role can do: read every row, write rows, drop tables, and on some configurations read files or run commands. CWE-89 is the formal classification, and the impact rating is high for exactly this reason.

Why AI-generated code keeps producing it

Three pressures push a model toward the string-building version.

It is the shorter answer. An f-string is one expression. A parameterized query is a query plus a tuple, and if the model is optimizing for a snippet that looks clean in a chat window, the concatenated version wins on line count.

Dynamic requirements defeat the safe path. Placeholders bind values, not identifiers. The moment a prompt says “let the user sort by any column,” the model cannot parameterize the column name, and the usual move is to abandon parameterization for the whole query rather than for the one part that needs it.

The training corpus is full of it. Twenty years of tutorials, forum answers, and blog posts demonstrate the concatenated form because it explains the concept in fewer characters. The model learned the shape that was most common, not the shape that was most correct.

The fix, and why it is not escaping

The correct fix is to send the query and the values as separate things, so the database parses the grammar before it ever sees the data:

# Parameterized. The driver sends the statement and the values separately.
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))
// node-postgres, same idea.
await client.query("SELECT * FROM users WHERE email = $1", [email]);
// Prisma tagged template: interpolations become bind parameters.
await prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`;

Note the last one carefully, because the difference is invisible at a glance. $queryRaw with a tagged template is safe: Prisma turns each ${} into a bind parameter. $queryRawUnsafe with a built string is not, and the name is the only warning you get.

What does not work is escaping by hand. Writing a function that doubles quotes or strips keywords puts you in a losing arms race against character encodings, comment syntax, and the specific dialect’s quoting rules. Every generation of developers has written that function and every generation of it has been bypassed. Parameterization does not sanitize the input, it removes the input’s ability to be syntax at all, which is a different and stronger claim.

The dynamic case, handled properly

When the variable part genuinely is an identifier, the answer is an allowlist, not escaping:

SORTABLE = {"created_at", "name", "email"}

def list_users(sort_by: str, direction: str):
    if sort_by not in SORTABLE:
        raise ValueError("unsupported sort column")
    order = "DESC" if direction.lower() == "desc" else "ASC"
    # sort_by is now one of three literals we wrote ourselves.
    return cursor.execute(
        f"SELECT * FROM users ORDER BY {sort_by} {order} LIMIT %s",
        (100,),
    )

The user’s string never reaches the query. It selects among strings we already control. That is the general shape of every safe dynamic query: map untrusted input to a known-good value, then use the known-good value.

How to find it in your own code

Static analysis catches this class reliably because the pattern is structural: a string that reaches a query sink and contains an interpolation. Our scan service runs OpenGrep as the primary multi-language engine, with bandit on Python and eslint-security on JavaScript layered on top when those languages are detected. The rules that matter here are the query sinks, and they fire on the f-string example above without needing to run it.

By hand, search for the sinks rather than for the vulnerability:

# Python
grep -rn "execute(f\"\|execute(\"\" *+\|text(f\"" --include=*.py .

# JavaScript and TypeScript
grep -rn "queryRawUnsafe\|\$executeRawUnsafe\|query(\`.*\${" --include=*.ts --include=*.js .

Then test the ones you find. A single quote in a form field is the cheapest probe in security: if the response changes from a normal result to a 500 or a database error string, the value reached the parser.

curl -s "https://your-app.example/api/users?email=%27" | head -20

A verbose SQL error in that response is two findings, not one. It confirms the injection and it confirms verbose error messages are leaking your schema to whoever asks.

Where it sits among the other failures

SQL injection is the loudest member of the injection family, but the pattern generalizes: any time untrusted input crosses into a language’s grammar, whether that is SQL, a shell, a template, or an LDAP filter, you get the same class of bug with a different blast radius. Command injection is the same mistake against /bin/sh, and prompt injection is the same mistake against a language model, which is why the newest vulnerability class on the list feels so familiar to anyone who has fixed this one.

It also interacts with access control. An app with correct row level security limits what an injected query can reach, because the database still applies the policy to the injected statement. That is not a reason to tolerate the injection, but it is a concrete argument for defense in depth: the apps where SQL injection turns into a full database dump are the ones where the connecting role could read everything anyway.

The four-line version: never let a value become syntax, use bind parameters everywhere including inside your ORM’s raw helpers, allowlist identifiers when the query shape has to vary, and probe with a single quote before you believe any of it.

ShareXLinkedIn