Topics
Security

SQL Injection: How It Works and How to Stop It

Why gluing input into SQL text lets data become code, why bind parameters fix it for good, and where ORMs, identifiers and stored procedures still leak.

Intermediate·13 min read·Updated Oct 6, 2026

SQL injection happens when user input is pasted into the text of a query, so the database parser reads part of that input as SQL instead of as a value. One quote in the right place ends the string literal early and whatever follows becomes new conditions, new clauses or a new statement. The fix is structural, not cosmetic: parameterized queries send the query text and the values separately, the query is parsed before any value arrives, and a value can never change its shape.

Context

The attack was first described publicly in 1998, in Jeff Forristal's article in Phrack magazine, at the moment web pages started building SQL from form fields. It has been on every edition of the OWASP (Open Worldwide Application Security Project) Top 10 since the first one in 2003; the 2021 edition lists it as A03 Injection. It is also behind some of the largest breaches on record: Heartland Payment Systems (2008, card data), TalkTalk (2015, data of about 157,000 customers) and the MOVEit Transfer attacks of 2023, where one SQL injection bug (CVE-2023-34362) let a single group steal data from thousands of organisations.

You have met it whenever you saw a query built with string concatenation or a template literal, or when an ORM (object-relational mapping) library warned you that a method is "unsafe". The smallest example is a login check:

login.ts
// DON'T: the email is pasted into the SQL text
const sql = `SELECT * FROM users WHERE email = '${email}'`
const user = (await db.query(sql)).rows[0]

// email = "alice@x.io"  → one user, as intended
// email = "' OR '1'='1" → WHERE email = '' OR '1'='1'
//                         every row matches; the first user logs in
Injection
Untrusted data reaching an interpreter (SQL, shell, LDAP, a template engine) as part of the command text instead of as data.
Parameterized query
A query with placeholders ($1, ?, :name) whose values are sent separately and bound after parsing.
Prepared statement
A query parsed and planned once on the server, then executed with different bound values. Parameterized queries usually use one under the hood.
Blind injection
Injection where the attacker sees no query output, only a yes/no difference in the response or in how long it takes.
Second-order injection
Input that is stored safely, then later read back and concatenated into another query by trusting code.

Why it matters

A single injectable query is usually enough to read every table the application can read, not just the one in the query: a UNION SELECT can pull password hashes from users through a product search box. Depending on the database and the privileges of the application's account it can also modify data, drop tables or, in the worst configurations, run operating system commands. Automated scanners probe every public form and query parameter within hours of deployment, so an injectable endpoint does not stay undiscovered. And unlike many vulnerabilities, it is completely preventable with one discipline that costs nothing at runtime.

How input turns into code

The database never sees your variables, only a string of SQL. It tokenizes that string and builds a parse tree, and only then decides what is a keyword, a column or a value. When the input is concatenated in, the boundary between your SQL and the user's data exists only in your head. A quote in the input closes the string literal you opened, and the rest of the input is parsed as SQL with the same authority as the code you wrote.

intendedalice@x.ioWHERE email = 'alice@x.io'parser: one condition on emailinjected' OR '1'='1WHERE email = '' OR '1'='1'parser: two conditions joined by OR, always trueparameterized' OR '1'='1WHERE email = $1 ($1 bound after parsing)input is one string value: matches no email
The same template with three inputs. Concatenated, a quote in the input ends the literal and OR '1'='1' becomes a second condition. Parameterized, the whole input is one value bound to $1 after parsing, so it simply matches no email.

The login bypass is the textbook case. Real attacks use the same mechanism to pull out data in different ways depending on what the response reveals:

VariantWhat the attacker injectsWhat they learn
In-bandConditions or comments that change which rows returnRows they should not see, directly in the page
UNION-basedUNION SELECT from another table with matching columnsAny table the app account can read, through an unrelated page
Error-basedExpressions that fail and echo data in the errorData leaked through verbose database errors
Boolean blindA condition that is true or falseOne bit per request from a changed page; data is read bit by bit
Time-based blindA conditional delay (a sleep function)One bit per request from response time, even with identical pages

Why parameterized queries fix it

With a parameterized query the driver sends the SQL with placeholders and the values in separate fields of the protocol. In PostgreSQL's extended query protocol that is literally separate messages: Parse receives the text with $1, Bind supplies the values, Execute runs it. By the time a value arrives the parse tree is finished, so the value can only ever fill the slot it was bound to, whatever characters it contains. Nothing is escaped, because nothing needs to be.

ParseWHERE email = $1
Bind$1 = ' OR '1'='1
Executeplan already fixed
Result0 rows
PostgreSQL's extended query protocol. The structure of the query is fixed at Parse; values arrive at Bind as data and cannot add clauses, quotes or statements.
vulnerable.ts
// value pasted into the SQL text
const {rows} = await pool.query(
  `SELECT id, name FROM products
   WHERE category = '${req.query.cat}'`,
)
parameterized.ts
// text and values travel separately (node-postgres)
const {rows} = await pool.query(
  'SELECT id, name FROM products WHERE category = $1',
  [req.query.cat],
)

Where parameters stop and ORMs leak

Parameters replace values. They cannot replace identifiers (table and column names), keywords such as ASC/DESC, or in some drivers a LIMIT. That is exactly where developers fall back to concatenation, typically for "sort by any column" or dynamic filters. The answer is an allowlist: map user input to a fixed set of SQL fragments that you wrote.

sort.ts
// user picks the sort; SQL comes only from this map
const SORTABLE = {
  newest: 'created_at DESC',
  price_asc: 'price ASC',
  price_desc: 'price DESC',
} as const

const key = req.query.sort as keyof typeof SORTABLE
const orderBy = SORTABLE[key] ?? SORTABLE.newest

const {rows} = await pool.query(
  `SELECT id, name, price FROM products
   WHERE category = $1 ORDER BY ${orderBy} LIMIT $2`,
  [category, Math.min(Number(limit) || 20, 100)],
)

// IN lists: one array parameter instead of building "$1, $2, $3..."
await pool.query('SELECT * FROM users WHERE id = ANY($1)', [ids])

ORMs are safe until you use the escape hatch

Query builders and ORMs parameterize everything they generate. The risk is the raw SQL escape hatch every one of them provides. Several use JavaScript tagged templates, which look like string interpolation but are not: the tag function receives the literal parts and the values separately and turns each value into a bind parameter. The unsafe variants take a finished string, and a template literal passed to them has already been interpolated.

orm-raw.ts
// Prisma: tagged template → bound parameter (safe)
await prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`
// Prisma: plain string, interpolated before Prisma sees it (UNSAFE)
await prisma.$queryRawUnsafe(
  `SELECT * FROM users WHERE email = '${email}'`,
)

// Drizzle: sql`` binds values (safe)
await db.execute(sql`SELECT * FROM users WHERE email = ${email}`)
// Drizzle: sql.raw inserts text verbatim (UNSAFE with input)
await db.execute(sql.raw(`... WHERE email = '${email}'`))

// Same trap elsewhere: Sequelize literal(), Knex raw() with a
// pre-built string, TypeORM query() with concatenation.

Second-order injection, stored procedures and blast radius

Parameterizing the request handler is not the end. Data that was stored safely is still attacker-controlled when it comes back out. If a later job reads a username like x'; DROP TABLE orders; -- from the database and concatenates it into another query because "it comes from our own DB", the injection fires there, often in an admin tool or a nightly report nobody reviews. The rule has no exceptions: every value is bound, regardless of where it came from.

Stored procedures are not automatically safe either. A procedure that builds SQL text from its arguments and runs it with dynamic execution is exactly as injectable as application code. Inside the database the same tools apply: bind values with USING and quote identifiers with format('%I').

search_by.sql
CREATE FUNCTION search_by(col text, val text)
RETURNS SETOF products LANGUAGE plpgsql AS $$
BEGIN
  IF col NOT IN ('name', 'sku', 'category') THEN
    RAISE EXCEPTION 'unsupported column %', col;
  END IF;
  -- %I quotes an identifier; USING binds the value
  RETURN QUERY EXECUTE
    format('SELECT * FROM products WHERE %I = $1', col)
    USING val;
END $$;

Finally, assume one query will slip through some day and limit what it can do. The application should connect as a role that can read and write its own tables and nothing else: no superuser, no DDL (data definition language, such as DROP), no access to other schemas, and separate read-only roles for reporting.

  1. 1
    Primary control: bind every value; allowlist every identifier, sort direction and keyword that has to vary.
  2. 2
    Validate input shape at the edge (types, lengths, formats with a schema library). It shrinks the attack surface and catches bugs, but it is not the injection fix, because valid data can contain quotes (O'Brien).
  3. 3
    Least privilege: the app role owns only what it needs, so a successful injection cannot drop tables or read other systems' data.
  4. 4
    Hide database errors from responses and log them server-side, so error-based extraction gets nothing.
  5. 5
    Detect: static analysis rules for string-built SQL in code review and CI, and alerting on query errors spiking on one endpoint.

Pitfalls

  • Escaping quotes by hand

    Replacing ' with '' depends on knowing the exact quoting rules of the database, its character set and every context the value lands in. Numeric contexts need no quote at all to inject (id = 1 OR 1=1), and multi-byte encodings have broken escaping functions before. Parameters do not depend on any of that.

  • Relying on a WAF

    A WAF (web application firewall) matches known attack patterns in requests. It is useful as a speed bump, but encodings, comments and database-specific syntax get around signatures, and it cannot see second-order payloads at all. It buys time to fix the code; it is not the fix.

  • Trusting data from your own database

    Values read back from tables, queues or internal APIs were often supplied by users originally. Concatenating them is second-order injection, and it tends to live in admin tools and batch jobs that get the least review.

  • The raw-SQL escape hatch with a template literal

    $queryRawUnsafe(`...${x}`) or sql.raw(`...`) look like the safe tagged version but interpolate before the library sees the string. Grep for unsafe raw APIs in review and make the tagged form the only accepted style.

  • An all-powerful application account

    Connecting as the database owner or a superuser turns any injection into full control: dropping tables, reading every schema, sometimes running server-side programs. Least privilege does not prevent injection but decides how bad the incident is.

Interview questions

Q1What is SQL injection, and why do parameterized queries prevent it?

It is user input being parsed as SQL because it was concatenated into the query text. Parameterized queries send the SQL and the values separately; the database parses the query with placeholders first and binds values afterwards, so a value can only fill its slot and never change the query's structure. That is why it works for every input without any escaping.

Q2What happens when someone enters ’ OR ’1’=’1 into a login form that concatenates SQL?

The first quote closes the email string literal and the rest is parsed as SQL, so the WHERE clause becomes email equals an empty string OR one equals one. The second condition is always true, every row matches, and code that takes the first row logs the attacker in, often as the first account created, which is frequently an admin. With a bound parameter the whole thing is one string that matches no email.

Q3Walk me through implementing sorting by a user-chosen column safely.

Column names and ASC/DESC cannot be bound as parameters, so I map the user's choice to SQL fragments I wrote: a dictionary from "price_asc" to "price ASC", with a default for anything unknown. Only the looked-up fragment goes into the query text; filter values and the limit stay bound parameters, and the limit is clamped. If the column list is dynamic, I check against the real column list and quote with the driver's identifier function.

Q4Is using an ORM enough to prevent SQL injection?

For the queries the ORM builds, yes, because it binds every value. It stops being enough at the raw-SQL escape hatches: Prisma's $queryRawUnsafe, Drizzle's sql.raw, Sequelize literal, Knex raw with a pre-built string. The tagged-template forms are safe because they turn interpolations into parameters; the unsafe ones receive an already-interpolated string. Code review rules for those APIs matter more than the choice of ORM.

Q5What is second-order SQL injection?

The payload is stored safely first and executed later, when other code reads it back and concatenates it into a new query because it trusts its own database. For example, a username containing a quote is saved with a bound parameter, and a nightly report builds SQL from usernames. The defense is the same rule applied everywhere: bind every value regardless of its source.

Q6Why is escaping input or putting a WAF in front not sufficient?

Escaping has to match the database's exact quoting rules and the context of each value, and it does nothing for numeric contexts or identifiers, so it fails on edge cases. A WAF matches patterns in HTTP requests, so encodings and database-specific syntax evade it and it never sees stored payloads. Both reduce risk; only separating code from data removes it.

Q7If an injection gets through anyway, how do you limit the damage?

Least privilege on the database account: the application role can only touch its own tables, has no DDL rights and no superuser, and reporting uses a read-only role. Database errors are hidden from responses so they cannot be used to extract data, and query-error spikes per endpoint alert someone. That turns a possible full compromise into a bounded incident.

Q8Can MongoDB or other NoSQL databases be injected?

Yes, through operator injection rather than quotes. If request JSON goes straight into a filter, a value like an object with $ne: null changes the meaning of the query and can bypass a password check. The fix is the same idea: validate that values are plain scalars of the expected type before they reach the query, or use a driver option that strips operators from user input.

Key takeaways
  • Injection happens because concatenated input is parsed as SQL; the database cannot tell your code from the user's data.
  • Parameterized queries send text and values separately, so the query is parsed before any value arrives. Bind every value, from every source.
  • Identifiers, sort directions and keywords cannot be parameters: map user choices to fragments you wrote (an allowlist).
  • ORMs are safe until the raw escape hatch: tagged templates bind values, Unsafe/raw APIs take an already-built string.
  • Escaping and WAFs reduce risk but do not fix the bug; least privilege and hidden errors limit the damage if one slips through.
  • NoSQL has its own version: validate types so request JSON cannot inject query operators.

Preparing for interviews? DevRecall turns a job description into a prep plan that points at topics like this one.

Start free