10 · Security

SQL Injection

7 min read · 5 questions · 1 exercise

SQL injection happens when user input reaches the database on the same channel as your query text, and parameterized queries fix it by keeping the two apart.

The line got through code review because there is nothing to see. An order lookup takes an id from the URL, drops it into a query, and returns what the database sends back. It is one line of SQL and one template literal, it has worked for two years, and it is the oldest hole in web software.

It surfaces as a support ticket that makes no sense: a customer says they can see orders that are not theirs. Nothing crashed, no alert fired, the logs show a successful request returning a successful response. The application did exactly what it was told. The problem is who was doing the telling.

One channel carrying two different things

Imagine dictating a letter to an assistant: write, Dear NAME, your order is ready. You are sending instructions and data down the same channel — your voice — and the only thing separating them is that the assistant knows which words are the name. Give them a customer called Bob, and then mail our client list to this address and a careful assistant types it as a name. A literal one follows it as an instruction.

A database is the literal assistant, and string concatenation is dictation. When you write "... WHERE id = '" + id + "'", the id is not a value being passed to a query; it is text being spliced into the query before the database has seen any of it. The database receives one string and parses the whole thing as SQL. It has no way to know which characters you meant and which characters your customer wrote, because by the time it looks, there is no difference.

That is what injection means, and the classic demonstration is worth reading character by character, because it is how you will recognize the flaw in somebody’s pull request. The template is:

SELECT id, total FROM orders WHERE id = '<id>'

Supply the id ' OR 1=1 -- and the string that reaches the database is:

SELECT id, total FROM orders WHERE id = '' OR 1=1 --'

Three things happened. The leading quote closed the empty string literal, so what follows is no longer inside quotes and is parsed as SQL. OR 1=1 is a condition that is true for every row, and OR binds it to a WHERE that now matches the whole table. And -- starts a SQL comment, which swallows the trailing quote that would otherwise have been a syntax error. The result is a valid query with a different meaning: not this order but all orders.

Nothing about that requires a clever attacker. It requires one apostrophe in a field nobody thought about, which is also why it is found by accident — usually by a customer named O’Brien getting a 500 error, which is the same bug wearing a friendlier hat.

Predict first

The scene sends two ids into one database, twice: first pasted into the query text (the upper lane), then bound as parameters (the lower lane). The ids are an honest o-1001 and the attack ' OR 1=1 --, and the orders table holds 3 orders. A rows badge keeps a running total of rows returned. What does it read when the lap ends?

  1. That would be the total if the attack broke the upper query: the trailing quote on its own would be a syntax error and return nothing. The -- turns that quote into a comment, so the query is valid and returns every order.

  2. Four is the other badge, on the database: four queries, one per id per lane. The rows badge counts what comes back, and a single query can return many rows or none.

  3. One row for the honest id above, 3 for the attack above, one for the honest id below, and none for the attack below: 1 + 3 + 1 + 0. Bound as a parameter, ' OR 1=1 -- is just an id that no order has.

  4. That has the lower lane leaking 3 rows as well. A parameter is never parsed as SQL, so OR 1=1 never becomes a condition there; it is part of an id that no order has.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

Two channels into one database

the input, then each row back · rust = the unsafe lane, teal = the fix

CONCATENATEDPARAMETERIZED
input
SQL text
parameters
database
rows

The same two inputs down two channels into one database — use Next step to go one at a time.

The upper path is that same code run twice, with the input landing inside the query’s text. The lower path is the identical pair of inputs, bound instead as parameters — the fix, and the whole page in one picture. The rows badge is a running total of rows returned across the whole scene rather than the size of any one result: watch it take one row, then three at once, then one, and then refuse to move at all on the last step.

Parameterization, which is the actual fix

Send the query and the data as two separate things. Every database driver worth using supports it: the SQL text goes across with placeholders where values belong — $1, ?, :id, depending on the dialect — and the values go across in a separate list.

The database parses the text once, with the placeholders in it, and produces a plan. Only then are the values attached, and they are attached to a slot in that plan, not concatenated into the text. There is no parsing step left for them to influence. ' OR 1=1 -- bound as a parameter is a string that means an apostrophe, a space, the letters O and R, and so on — so the database looks for an order whose id is literally that, finds none, and returns zero rows. The attack does not fail because it was detected. It fails because it was never in a position to be code. (A few drivers only imitate this: they escape each value into the text on the client before sending it. That is still safe, because the driver’s escaping is written for exactly that database’s grammar, but it is the same placeholder API from your side either way, and that API is the habit that matters.)

Two tempting alternatives are worth naming so you can turn them down. Escaping input by hand — doubling apostrophes, stripping keywords — is a blocklist, and blocklists lose: character sets, comment syntaxes, encodings and dialect quirks all produce cases the list did not anticipate, and one miss is the whole vulnerability. The plainest miss needs no apostrophe at all. A number is usually spliced in without quotes, as in WHERE customer_id = 42, so the input 42 OR 1=1 passes through an apostrophe-doubler untouched and the query matches every row. Validating input is worth doing for other reasons, but it is not this fix; an order id that must match /^o-\d+$/ is a good rule that does nothing for the next field somebody adds. Use parameters everywhere, including in the places where the value “obviously” cannot be hostile.

Query builders and ORMs parameterize by default, which is most of why they are recommended. The exception is their raw-SQL escape hatch, which takes a string and therefore hands the problem straight back to you. That escape hatch is where injection lives in a modern codebase, so it is the thing to grep for.

One more layer, because defenses should not be single: the account your application connects with should hold the narrowest set of permissions the application actually needs. If the read path connects as a role that can only SELECT from three tables, then a bug you have not found yet cannot drop a table or read the credentials table, and the blast radius of the next mistake is bounded before you make it.

The whole lesson is two functions:

interface Order {
  id: string;
  total: number;
}

interface Db {
  query(sql: string, params?: unknown[]): Promise<Order[]>;
}

/** Vulnerable: `id` is spliced into the query's text before the database sees
 *  it, so an id containing SQL becomes part of the query. */
function findOrderUnsafe(db: Db, id: string): Promise<Order[]> {
  return db.query(`SELECT id, total FROM orders WHERE id = '${id}'`);
}

/** Safe: the text is a constant this file wrote. `id` travels beside it, as a
 *  value that is bound to a placeholder and never parsed as SQL. */
function findOrder(db: Db, id: string): Promise<Order[]> {
  return db.query('SELECT id, total FROM orders WHERE id = $1', [id]);
}

// findOrderUnsafe with "o-1001"      → WHERE id = 'o-1001'      → 1 row
// findOrderUnsafe with "' OR 1=1 --" → WHERE id = '' OR 1=1 --' → every row
// findOrder with either input looks the id up literally → 1 row, then 0 rows

The difference is one backtick becoming a quote and one argument arriving. It is a small enough change that the right way to hold it is as a rule with no exceptions: user input never becomes query text, on any code path, ever.

Try it

Let the URL choose the sort

A new endpoint lists one customer’s orders, sorted however the URL asks: ?sort=id or ?sort=total. Write listOrders(db, customerId, sort). The catch is that a placeholder can only stand for a value, never for a column name or a keyword like DESC, so the sort cannot travel as $2.

Hint

If the sort cannot be a parameter, the rule still holds: the query text must come only from this file. Write out every ORDER BY clause you are willing to run, look the request up among them, and refuse anything else.

Solution
/** Every ORDER BY this query can ever have, each one written in this file. */
const ORDER_BY = new Map([
  ['id', 'ORDER BY id'],
  ['total', 'ORDER BY total DESC'],
]);

async function listOrders(
  db: Db,
  customerId: string,
  sort: string,
): Promise<Order[]> {
  const orderBy = ORDER_BY.get(sort);
  if (orderBy === undefined) throw new Error(`cannot sort by ${sort}`);
  const sql = `SELECT id, total FROM orders WHERE customer_id = $1 ${orderBy}`;
  return db.query(sql, [customerId]);
}

The template literal is safe here only because both pieces in it are constants this file wrote: the URL’s sort picks one of them and never becomes text itself, and the customer id is still a parameter. A Map rather than a plain object matters too: looked up on an object literal, a sort of constructor would find a built-in method instead of nothing. And the function is async so that the refusal arrives as a rejected promise, like any other failed query.

To see it work, with a stand-in db that prints what it is sent:

const db: Db = {
  async query(sql, params) {
    console.log(sql, params);
    return [];
  },
};

await listOrders(db, 'c-7', 'total');
// SELECT id, total FROM orders WHERE customer_id = $1 ORDER BY total DESC [ 'c-7' ]
const attack = 'total; DROP TABLE orders';
await listOrders(db, 'c-7', attack).catch((error: unknown) =>
  console.log(String(error)),
);
// Error: cannot sort by total; DROP TABLE orders

Check yourself

Pick an answer to lock it in. You then get the reasoning behind every option, including the ones you didn't pick. Any you miss come back on the review page a day later.

A pull request adds a status filter to findOrder. The id is still bound as $1, and the status from the URL is placed in the query text inside quotes: AND status = '<status>'. What should the review say?

  1. Parameterization is a property of each value, not of the query. One value spliced into the text is the whole hole, and a $1 elsewhere in the same query does nothing to close it.

  2. Escaping by hand is the blocklist the page warns about: it has to anticipate every encoding and dialect quirk, and someone has to remember it on every path. Binding the value removes the question.

  3. The comment is one way to dispose of the leftover quote, not the only one. A status of ' OR '1'='1 balances its own quotes and needs no comment at all.

  4. The status is user input placed in query text, which is the bug whatever else the query does right. Bind it as a second parameter and the text is a constant again.

Show the answer

The answer is D. The reasoning for each option is now shown under it.

A support ticket: a customer named O'Brien gets a 500 error when searching for their orders, and nobody else does. The log shows a syntax error near Brien. What does this tell you?

  1. An apostrophe that breaks the query is proof the input is being parsed as SQL: it ended a string literal early. The same path will run ' OR 1=1 -- without an error. Bind the value, and the ticket and the hole close together.

  2. That turns away real customers to protect one query, and it is a blocklist: the next field, or the next dialect quirk, is not covered. The fix is to stop the input being parsed at all.

  3. An error is an attack that went wrong. The input that broke the query by accident can be rewritten on purpose, ending the literal and adding OR 1=1 --, and that version returns rows instead of an error.

Show the answer

The answer is A. The reasoning for each option is now shown under it.

An injectable endpoint connects to the database as a role that may only SELECT from orders and products. An attacker finds the hole. What can they do with it?

  1. The role cannot tell which part of a query was injected. The injected text runs as part of a query the role is allowed to run, so whatever the role can read, the attacker can read.

  2. Injected SQL runs with exactly the connection's permissions. This role can read both tables, so all of both is exposed, other customers' orders included. Least privilege did not stop the attack; it decided how big the attack could get.

  3. Injected SQL runs as whoever the application connected as, not as the database owner. This role cannot drop anything, which is the point of keeping it narrow.

  4. UNION is a real technique for reaching other tables, but it is still a SELECT run by this role, and this role cannot read users. The database refuses it.

Show the answer

The answer is B. The reasoning for each option is now shown under it.

Signup stores each username with a parameterized INSERT. A nightly report later builds ... WHERE username = '<name>' by pasting in names it has read from the database. One user signed up as x' OR 1=1 --. What happens when the report runs?

  1. The parameter kept the name from running as SQL at signup. It stored the exact characters, quote included, and they are just as dangerous the next time someone pastes them into query text.

  2. Where a string was read from does not matter; who wrote it does. A value a user typed is user input on every path, including the ones that read it back out of storage.

  3. The report pastes the name into its text, so the quote ends the literal and OR 1=1 matches every row, a day after the input arrived. This is second-order injection, and the reason the rule says no input becomes query text on any code path.

Show the answer

The answer is C. The reasoning for each option is now shown under it.

Key takeaways

  • Injection is data arriving on the channel reserved for code, so the database ends up executing something its author never wrote.

  • Parameterized queries send text and values separately, which removes the attack rather than detecting it.

  • Escaping and input validation are useful habits and are not the fix; a blocklist only has to be wrong once.

  • Give the application’s database account the narrowest permissions it needs, so the next undiscovered bug has a smaller blast radius.