Quick Answer

SQL injection happens when user input is concatenated into a query, so the database parses it as SQL instead of data. Typing a quote and two dashes into a login field can comment out the password check entirely. The fix is parameterised queries: the query text goes to the database first with placeholders, and values arrive separately as data that is never parsed as SQL. Escaping quotes by hand fails, especially in numeric contexts where there is no quote at all.

How a login query becomes an auth bypass

Every SQL injection starts the same way: a string that came from a user gets glued into a query, and the database has no way to tell which part you wrote and which part the user wrote. Here is the login check most people write first.

# Never do this
email = request.form["email"]
password = request.form["password"]
cur.execute(
    f"SELECT id FROM users WHERE email = '{email}' AND password = '{password}'"
)
row = cur.fetchone()

Now put rahul@example.com' -- into the email box. The text sent to the database becomes:

SELECT id FROM users WHERE email = 'rahul@example.com' -- ' AND password = 'anything'

Two dashes start a comment in SQL, so everything after them is ignored, including the entire password check. The query now means "find the user with this email", it succeeds, and your code logs the attacker in as Rahul. No password was ever needed. One detail worth knowing: MySQL requires whitespace after the two dashes for it to count as a comment, and also accepts a hash character, so payloads look slightly different per database.

If the attacker does not know a valid email, the payload has to defeat the password check as well, and the exact payload matters more than people expect. ' OR '1'='1' -- in the email box produces WHERE email = '' OR '1'='1' -- ' AND password = '...'. The comment removes the password condition, what is left is always true, and the query returns whichever row the database hands back first, often the oldest account and therefore often an admin, though nothing guarantees that order without an ORDER BY.

Compare that with the payload people usually quote, ' OR '1'='1 with no comment. In the email box it produces WHERE email = '' OR '1'='1' AND password = '...', and because AND binds tighter than OR the database reads it as email = '' OR ('1'='1' AND password = '...'), which still requires the password to match. The same payload typed into the password box does bypass the check, because then the always-true test is the last condition. Operator precedence is the difference between the two, which is why testing an injection means reading the resulting query rather than trusting a payload you copied.

From there the same hole gives more: a UNION SELECT can pull columns out of other tables, and on some setups stacked statements let an attacker append a second command entirely.

Notice what the bug is not. It is not that the input contained a quote. It is that the query text and the data were the same string by the time the database parsed it. Any fix that does not separate those two things is patching symptoms.

Why escaping quotes by hand fails

The instinctive fix is to strip or escape quotes. It fails for several independent reasons, and each one has caused real breaches.

Numeric contexts have no quotes to escape. This is the one that catches people who thought they were done:

# Escaping quotes does nothing here
cur.execute(f"SELECT * FROM orders WHERE id = {order_id}")

Send 1 OR 1=1 as the order ID and every order comes back. There was never a quote in the payload, so a quote-escaping function had nothing to do.

Escaping is dialect and connection specific. The correct escape depends on the database, on the SQL mode in use, and historically on the connection character set. There is a well-known class of bug where an application set a multibyte connection encoding in a way the escaping function did not know about, and a crafted byte sequence swallowed the escaping backslash and released the quote. A generic "remove bad characters" helper written for one project cannot know any of this.

Second-order injection. You escape on the way in, store the escaped-looking value, and it lands in the table as ordinary text such as O'Brien. Months later a different part of the codebase, maybe a report generator, concatenates that stored value into a new query. The original escaping is long gone and the payload fires from your own database.

Blocklists lose. Filtering the word UNION invites UnIoN, comment-splitting and encoded variants. You are trying to enumerate every way to express an attack in a language designed to be expressive. The defender has to be right every time and the attacker only once.

The honest summary: hand-escaping asks you to reimplement part of a SQL parser correctly, in every code path, forever. The database already ships a mechanism that makes the question irrelevant.

Parameterised queries, the actual fix

A parameterised query, also called a prepared statement, sends the query text to the database first, with placeholders where the values go. The database parses and plans it while the values are still absent. The values arrive afterwards as data, on a separate part of the protocol, and are never parsed as SQL. That is why quotes in a value stop mattering: there is no parsing step left for them to escape from.

# Python, sqlite3 - placeholder style is ?
cur.execute(
    "SELECT id FROM users WHERE email = ? AND active = 1",
    (email,),
)

Placeholder syntax differs by driver, which trips people up when they move between projects. sqlite3 uses ?. psycopg and mysql-connector-python both use %s, which is not string formatting even though it looks like it. Postgres itself uses $1 numbering when you write SQL directly.

// Node with mysql2
const [rows] = await conn.execute(
  'SELECT id FROM users WHERE email = ? AND active = 1',
  [email]
);

In mysql2, .execute() uses a real prepared statement while .query() with placeholders does client-side escaping instead. Both are far better than concatenation, but prefer .execute().

<?php
$stmt = $pdo->prepare('SELECT id FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();

With PDO, set PDO::ATTR_EMULATE_PREPARES to false in your connection options. With emulation on, PDO builds the final query string itself rather than letting the server do the binding, which is usually fine but is not the guarantee you think you are buying.

// Java
PreparedStatement ps = conn.prepareStatement(
    "SELECT id FROM users WHERE email = ?");
ps.setString(1, email);
ResultSet rs = ps.executeQuery();

The mistake to watch for in Java is building the string you pass to prepareStatement by concatenation and then congratulating yourself for using a prepared statement. The protection comes from the placeholder, not from the class name.

What you cannot parameterise

Placeholders bind values. They cannot bind identifiers such as table names, column names, or keywords like ASC and DESC. This surprises people building a sortable table, and it is where injection sneaks back into an otherwise clean codebase.

# This does not work - the driver will not substitute an identifier
cur.execute("SELECT * FROM users ORDER BY ? ?", (column, direction))

The correct pattern is an allowlist. You decide the legal values in code, compare against them, and only then interpolate. The user chooses from a set you defined rather than supplying text.

ALLOWED_SORT = {"name", "created_at", "total"}

if column not in ALLOWED_SORT:
    raise ValueError("bad sort column")
direction = "DESC" if str(direction).upper() == "DESC" else "ASC"

cur.execute(f"SELECT * FROM users ORDER BY {column} {direction}")

The other common surprise is an IN clause. One placeholder binds one value, so a list needs one placeholder per item, generated from the length of the list rather than from anything the user typed.

ids = [7, 12, 40]
placeholders = ",".join("?" for _ in ids)
cur.execute(f"SELECT * FROM users WHERE id IN ({placeholders})", ids)

Also remember that LIKE patterns are values, so they bind normally, but the wildcard characters are part of the data. Build the pattern in your language and bind the whole thing:

cur.execute("SELECT * FROM users WHERE city LIKE ?", (f"%{term}%",))

If the user sends a percent sign it will act as a wildcard inside their own search, which is a behaviour question rather than a security hole. Escape it only if you need literal matching.

ORMs, their limits, and defence in depth

Every mainstream ORM parameterises for you. Django querysets, Sequelize model methods, Prisma, Hibernate and Eloquent all bind values rather than concatenating them, and that is a genuine reason to use one. The gap is that all of them provide an escape hatch to raw SQL, and the escape hatch is where injection comes back, usually in the one complicated reporting query nobody wanted to express through the ORM.

// Prisma: safe. The tagged template binds ${email} as a parameter.
const users = await prisma.$queryRaw`SELECT id FROM users WHERE email = ${email}`;

// Prisma: unsafe. The string is fully built before Prisma ever sees it.
const unsafeUsers = await prisma.$queryRawUnsafe(
  `SELECT id FROM users WHERE email = '${email}'`
);

The same distinction exists elsewhere under different names. Django .raw() accepts a params argument and is safe when you use it; interpolating into the SQL string is not. Sequelize sequelize.query() accepts replacements or bind values; a template literal in the query text is not protected. The rule generalises: if you can see the user value inside the query string in your editor, it is already too late.

ORMs also have injection-adjacent problems that parameterisation does not touch. Passing a request body straight into a filter or update lets a user choose which fields to match or change, so allowlist the fields you accept. And anything that builds a query from a user-supplied structure, including some search and filter APIs, needs the same allowlist discipline as sorting.

Finally, assume something will slip through and reduce the blast radius. Give the application a database user with only the privileges it needs, so it cannot drop tables or read the credentials table if it never should. Return generic error messages to the client, because verbose database errors are how an attacker maps your schema without guessing. Keep backups you have actually restored once. None of these prevent injection, but they change a total compromise into a contained incident, which is the difference that matters at three in the morning.

Frequently Asked Questions

If I use an ORM, can I stop worrying about SQL injection? Mostly, but not entirely. Normal ORM query methods bind values as parameters, so ordinary CRUD is safe. The risk moves to the raw SQL escape hatch every ORM provides, which is exactly where the complicated reporting query ends up. It also does not cover cases where the user controls a column name, a sort direction or a filter key, because those are identifiers and cannot be bound as parameters.
Does input validation stop SQL injection? It reduces the surface but it is not the fix. Validating that a pincode is six digits or that an email looks like an email is good practice and blocks many payloads by accident. It fails for any field that legitimately contains free text, and it fails completely for names with apostrophes, which you must accept. Validate for correctness, parameterise for safety, and do not let one substitute for the other.
What is blind SQL injection? It is injection where the application never shows you the query result or the error, so the attacker infers data one bit at a time. They send a condition that changes the response, for example whether a page renders or how long it takes, and read the answer from that difference. Time-based versions use a sleep function to make the delay measurable. Hiding error messages therefore makes exploitation slower, not impossible.
Are stored procedures automatically safe? No. A stored procedure that builds a query string from its arguments and runs it with dynamic SQL is exactly as vulnerable as application code doing the same thing. The safety comes from parameter binding, not from the code living inside the database. Stored procedures that use their parameters directly in static SQL are fine, and calling any procedure with bound parameters from your application is fine.
Why is a limited database user worth setting up? It bounds the damage when something does slip through. If the account your web app connects with cannot drop tables, cannot read an unrelated schema and cannot write files, an injection that would have been catastrophic becomes a data-read on one schema. It also catches mistakes early in development, because code that tries to do something it should never do fails loudly instead of quietly succeeding.