Skip to content

Web Development · Application Security

How to Prevent SQL Injection: Parameterized Queries, ORMs, and the Gaps They Miss

SQL injection is still landing CVSS 10.0 vulnerabilities in production software in 2026, including the Metabase bug that led to real breaches this month. Here's how parameterized queries actually stop it, where ORMs quietly reintroduce the bug, and how to verify your app is actually safe.

Abhishek Gupta

Abhishek Gupta

6 min read

How to Prevent SQL Injection: Parameterized Queries, ORMs, and the Gaps They Miss

Sponsored

Share

The vulnerability that led to real customer data walking out of two companies this month, the Metabase breach fallout at Framework and Trezor’s shipping partner, traces back to an unauthenticated SQL injection with a CVSS score of 10.0, the maximum possible. SQL injection has been on every security top-ten list since OWASP started publishing one, and it is still landing critical CVEs in widely deployed software in 2026. The fix has been well understood for just as long: stop building queries by gluing strings together, and start sending untrusted input as data instead of as code.

Why string concatenation is the bug

A query built like this treats user input as part of the SQL itself:

// VULNERABLE: user input becomes part of the query text
const query = `SELECT * FROM users WHERE email = '${email}'`;
db.query(query);

If email is exactly what it looks like, an email address, this works fine. If an attacker submits ' OR '1'='1, the query the database actually executes is:

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

That’s a syntactically valid query that returns every row in the table, because '1'='1' is always true. The attacker didn’t hack anything in the traditional sense, they just supplied input that the application trusted enough to treat as code. More sophisticated payloads chain additional clauses to extract data from other tables, modify rows, or, as in the Metabase CVE, escalate to an admin account through a password-reset endpoint that built a query the same unsafe way.

Diagram comparing a string-concatenated query, where user input becomes part of the executed SQL, against a parameterized query, where input travels as a literal value

Parameterized queries fix the actual problem

A parameterized query (also called a prepared statement) separates the query structure from the values, and sends them to the database as two distinct things:

// SAFE: the query structure and the value travel separately
const query = 'SELECT * FROM users WHERE email = $1';
db.query(query, [email]);
# Python, using a DB-API driver
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))

The database receives the query template first, compiles it, and only then substitutes the parameter as a literal value, never as executable SQL syntax. An attacker submitting ' OR '1'='1 as the email parameter gets a lookup for a user whose email is literally the string ' OR '1'='1, which almost certainly returns nothing, not a bypassed filter. This isn’t a filter that catches known-bad patterns, it’s a structural guarantee that input can never be interpreted as part of the query, which is why it closes the entire class of vulnerability rather than a subset of known payloads.

Where ORMs quietly bring the bug back

Most modern ORMs (Prisma, Drizzle, SQLAlchemy, ActiveRecord) parameterize queries built through their standard query-builder API by default, which covers the large majority of everyday application code without you thinking about it. The bug comes back in three specific places developers reach for when the query builder feels limiting:

// Drizzle's sql template tag parameterizes correctly:
await db.execute(sql`SELECT * FROM users WHERE email = ${email}`);

// But raw string interpolation into a raw query does NOT:
await db.execute(sql.raw(`SELECT * FROM users WHERE email = '${email}'`));

The second pattern reintroduces exactly the same vulnerability the ORM was protecting against, because sql.raw explicitly opts out of parameterization. This is the pattern to search for in code review: any raw-query escape hatch, any string template used to build query text, and any place a “quick fix” for a query the builder couldn’t express reached for concatenation instead of a parameter.

Dynamic identifiers are the second gap. Parameterization protects values, not table or column names, because those aren’t things a prepared statement placeholder can represent:

// Still vulnerable even with a "parameterized" value elsewhere in the query:
const query = `SELECT * FROM ${tableName} WHERE id = $1`;

If tableName comes from user input, an attacker controls part of the query structure directly. The fix is an allowlist, not a parameter: validate tableName against a fixed set of known-safe values before it ever reaches the query, and reject anything that doesn’t match exactly.

Defense in depth, once parameterization is in place

Parameterized queries are the primary control, not the only one worth having. A database user scoped to the minimum permissions your application actually needs (no DROP, no access to tables the app doesn’t touch, separate read-only credentials for reporting tools) limits what an injection bug that slips past code review can actually do. This is the same lesson the Metabase breach fallout surfaces from a different angle: a BI tool holding broad, standing database credentials turned one application-layer bug into access to everything connected to it. Least-privilege database accounts don’t prevent the injection, but they shrink the blast radius when something does get through.

Input validation is a reasonable additional layer (an email field that rejects obviously malformed input fails fast and reduces noise), but it should never be the only defense. Attackers have decades of practice bypassing blocklists and escaping schemes built around specific character patterns, and a validation rule that’s slightly too permissive, or a code path that skips it, brings you right back to the original vulnerability.

Checking your own code

Search your codebase for query strings built with template literals, + concatenation, or .format() calls that include a variable, then check whether each one goes through a parameterized call or a raw execution path. Review every sql.raw, .raw(), or equivalent escape hatch in your ORM by hand, since these are exactly where automated linting is least reliable. Running a scanner like sqlmap against a staging environment you own, or wiring a SAST tool into CI, catches what manual review misses on a large codebase, but treat it as a second check, not the primary defense.

If you’re auditing a client codebase or reviewing a legacy system before a migration, this kind of security pass is worth doing explicitly rather than assuming an ORM has already handled it. The fix for SQL injection has been the same for two decades: send data as data, never as code, and treat every place that rule gets broken as a bug regardless of how convenient the shortcut looked at the time.

Frequently asked questions

What is SQL injection?
SQL injection is a vulnerability where an application builds a database query by inserting untrusted input directly into the query text, letting an attacker supply input that changes the query's meaning instead of just supplying a value. A login form that builds `SELECT * FROM users WHERE email = '` + userInput + `'` lets an attacker submit input like `' OR '1'='1` and turn a lookup for one user into a query that returns every row.
Do parameterized queries fully prevent SQL injection?
For the specific class of injection that comes from user-supplied values, yes, when used correctly and consistently. The database driver sends the query template and the parameter values as two separate things, so a value like `' OR '1'='1` is treated as a literal string to search for, not as SQL syntax. Parameterization doesn't cover dynamic identifiers (table names, column names, sort direction) passed as raw strings, which need a different approach: an allowlist, not a parameter.
Does using an ORM mean I'm automatically safe from SQL injection?
No. Most ORMs parameterize queries built through their standard query builder methods, which covers the majority of everyday queries. The risk reappears the moment code drops into a raw query escape hatch, string-concatenates a value into a `WHERE` clause the ORM builder didn't anticipate, or passes user input as a column or table name, since parameterization only protects values, not identifiers.
Is escaping special characters enough to prevent SQL injection?
It's an incomplete substitute for parameterization. Escaping requires correctly anticipating every dangerous character sequence for your specific database and driver, and encoding edge cases (multi-byte characters, alternate quote styles, database-specific escape sequences) have produced bypasses for as long as escaping-based defenses have existed. Parameterized queries avoid the problem entirely by never treating input as code in the first place, which is why every serious security guideline treats them as the primary control, not escaping.
How do I check if my app is actually vulnerable?
Grep your codebase for string concatenation or template literals feeding directly into a query call, review every raw query and ORM escape hatch by hand, and run an automated scanner (sqlmap against a staging environment you own, or a SAST tool in CI) as a second check, not a first line of defense. Manual review catches what pattern matching misses, particularly in dynamically built queries where the injection point isn't an obvious string concatenation.

Sources

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored