SQL injection (SQLi) happens when an application builds a SQL query by gluing user input into the query string. The database cannot tell which part was written by the developer and which part came from the attacker, so the attacker gets to rewrite the query. It is one of the oldest web vulnerabilities and it is still everywhere: the OWASP Top 10:2025 keeps Injection in the list (A05) and notes more than 14,000 CVEs tied to SQL injection alone.

The good news: SQL injection is one of the few vulnerability classes with a near-perfect fix. This guide covers how it works, the main types, vulnerable vs fixed code in five stacks, the ORM traps that still bite experienced teams, and how to find it in your own codebase.

How SQL injection works

Take a login endpoint that builds its query like this:

query = "SELECT * FROM users WHERE email = '" + email + "' AND password_hash = '" + hash + "'"

A normal user types [email protected] and the query does what you expect. An attacker types this into the email field instead:

' OR '1'='1' -- 

The final query sent to the database becomes:

SELECT * FROM users WHERE email = '' OR '1'='1' -- ' AND password_hash = '...'

The quote closes the string early, OR '1'='1' makes the condition always true, and -- comments out the password check. The database returns every user, and the app logs the attacker in as the first one, often an admin.

The root cause is always the same: data is being treated as code. Escaping quotes by hand, blocklisting words like UNION or relying on a WAF only make the attack harder. Separating data from code is what actually fixes it.

Types of SQL injection

TypeHow the attacker gets dataTypical payload idea
In-band: UNION-basedAppends a second SELECT whose rows show up in the normal response' UNION SELECT email, password_hash FROM users --
In-band: error-basedForces a database error whose message leaks dataType conversion errors that print a value
Blind: boolean-basedAsks yes/no questions and watches the page change' AND 1=1 -- vs ' AND 1=2 --
Blind: time-basedMakes the database sleep when a condition is trueSLEEP(5) in MySQL, pg_sleep(5) in PostgreSQL
Out-of-bandMakes the database send data to an external server (DNS or HTTP)Database-specific network functions
Second-orderPayload is stored safely, then used unsafely laterA username like admin' -- reused in another query

In-band (UNION and error-based)

In-band SQLi is the easiest to exploit because the results come back in the same channel as the normal response. With UNION-based injection, the attacker first figures out how many columns the original query returns, then appends UNION SELECT to pull data from any table the database user can read. Error-based injection works when the app shows raw database errors: the attacker crafts input that forces an error message containing the data they want.

Blind (boolean and time-based)

When the app shows no errors and no query results, the attacker can still extract data one bit at a time. Boolean-based blind SQLi compares responses: if AND 1=1 returns the product page and AND 1=2 returns "not found", the attacker can ask questions like "is the first character of the admin hash greater than m?". Time-based blind SQLi uses delays instead: if the response takes five seconds, the answer is yes. It is slow by hand but trivial to automate, which is why "we don't show errors" is not a defense.

Second-order

Second-order SQL injection is the one code reviews miss. The input is stored correctly, for example with a parameterized INSERT. Later, a different part of the code (a background job, an admin report, a password reset flow) reads that value from the database and concatenates it into a new query, trusting it because "it came from our own database". Any value that originally came from a user is untrusted, no matter where you read it from.

Vulnerable vs fixed code, by language

The fix is the same everywhere: use parameterized queries (also called prepared statements or bind variables). The query structure is sent to the database first, and values are sent separately, so they can never change the query.

Node.js (pg / mysql2)

// Vulnerable
const { rows } = await db.query(
  `SELECT * FROM orders WHERE user_id = ${req.params.id}`
);

// Fixed (pg uses $1, $2... ; mysql2 uses ?)
const { rows } = await db.query(
  "SELECT * FROM orders WHERE user_id = $1",
  [req.params.id]
);

Python (psycopg / sqlite3)

# Vulnerable: f-strings and % formatting build the SQL string
cur.execute(f"SELECT * FROM users WHERE email = '{email}'")

# Fixed: pass values as the second argument
cur.execute("SELECT * FROM users WHERE email = %s", (email,))  # psycopg
cur.execute("SELECT * FROM users WHERE email = ?", (email,))   # sqlite3

Note the subtle trap: cur.execute("... = %s" % email) looks parameterized but is plain string formatting. The value must be passed as a separate argument.

Ruby on Rails (ActiveRecord)

# Vulnerable: string interpolation inside a SQL fragment
User.where("email = '#{params[:email]}'")

# Fixed: hash conditions or placeholders
User.where(email: params[:email])
User.where("email = ?", params[:email])

# Identifiers cannot be bound: allowlist them
SORTABLE = %w[created_at name].freeze
column = SORTABLE.include?(params[:sort]) ? params[:sort] : "created_at"
User.order(column)

Recent Rails versions reject many raw SQL strings passed to order, but find_by_sql, pluck with Arel.sql, joins with strings and exists? with strings are still easy to misuse.

PHP (PDO)

// Vulnerable
$result = $pdo->query("SELECT * FROM users WHERE id = " . $_GET['id']);

// Fixed
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$_GET['id']]);
$user = $stmt->fetch();

Functions like mysqli_real_escape_string are not a substitute: they do nothing for numeric contexts like WHERE id = 1 OR 1=1, where there are no quotes to escape.

Java (JDBC and JPA)

// Vulnerable
Statement st = conn.createStatement();
ResultSet rs = st.executeQuery(
  "SELECT * FROM accounts WHERE owner = '" + owner + "'");

// Fixed
PreparedStatement ps = conn.prepareStatement(
  "SELECT * FROM accounts WHERE owner = ?");
ps.setString(1, owner);
ResultSet rs = ps.executeQuery();

// JPA: JPQL is injectable too if you concatenate
List<Account> list = em.createQuery(
    "SELECT a FROM Account a WHERE a.owner = :owner", Account.class)
  .setParameter("owner", owner)
  .getResultList();

ORM pitfalls: where "we use an ORM" stops protecting you

ORMs parameterize values when you use their query builders. The problem is that every ORM also ships a raw SQL escape hatch, and that is where SQL injection comes back:

  • Prisma:$queryRaw`... ${id}` (tagged template) is parameterized. $queryRawUnsafe("... " + id) is not.
  • Sequelize / Knex:sequelize.query() and knex.raw() are safe only with replacements, bind or ? bindings, never with template strings.
  • Django:Model.objects.raw(), .extra() and cursor.execute() need params lists. raw(f"...") is vulnerable.
  • SQLAlchemy:text("... WHERE id = :id") with bound params is fine; text(f"... {id}") is not.
  • ActiveRecord: string conditions in where, order, group, having, joins and find_by_sql.

Two more traps. First, identifiers cannot be parameterized: table names, column names and sort directions (ASC/DESC) must be validated against an allowlist. Second, stored procedures are not automatically safe: a procedure that builds dynamic SQL with concatenation inside is just as injectable.

NoSQL injection, briefly

Moving to MongoDB does not make injection disappear, it changes its shape. The classic case in Node.js is operator injection:

// Vulnerable: req.body.password can be an object like {"$ne": null}
const user = await User.findOne({
  email: req.body.email,
  password: req.body.password
});

// Fixed: validate types before querying
if (typeof req.body.email !== "string" || typeof req.body.password !== "string") {
  return res.status(400).end();
}

If the body is JSON, an attacker can send {"email": "[email protected]", "password": {"$ne": null}} and match any password. Validate input with a schema (Zod, Joi, JSON Schema), cast values to the expected type and strip keys that start with $. Avoid $where and anything that evaluates JavaScript on the server.

Defense in depth

  • Least privilege: the app's database user should not be able to drop tables, read other schemas or run system functions.
  • Generic errors: log database errors server-side, return a generic message to the client. This kills error-based extraction.
  • Input validation: reject a non-numeric id early. It does not replace parameterization, but it shrinks the attack surface.
  • WAF as a seatbelt, not a fix: WAFs block common payloads, but encoding tricks and blind techniques get through. Fix the code.

How to find SQL injection in your codebase

  1. Grep for the obvious patterns. Search for SQL keywords next to +, template literals, f-strings, % formatting or #{}, and for raw methods: queryRawUnsafe, .raw(, find_by_sql, createStatement, executeQuery, .extra(.
  2. Review data flow, not just lines. The dangerous code is often a helper that builds a WHERE clause three files away from the controller. A secure code review follows user input from the request to the query.
  3. Run SAST. Static analysis traces tainted input to database sinks across files and catches the patterns humans skim over. If you are new to the distinction, read SAST vs DAST.
  4. Add security tests. For each endpoint that touches the database, add a test that sends ' OR '1'='1, 1 OR 1=1 and a JSON operator payload, and asserts the response is a 4xx or an empty result, never extra data.
  5. Test the running app. DAST tools and sqlmap can confirm exploitability, but only run them against systems you own or are explicitly authorized to test.

If you want to automate the first three steps, scan your repository for vulnerabilities. With Nurbak you connect GitHub and scan a repo; Nurbak's own self-hosted AI model analyzes the code (the analysis does not send your code to OpenAI or Anthropic) and reports exploitable issues like SQL injection with the exact file and line, plus a plain-language explanation. It can also open a Pull Request with the fix and a security regression test. The free scan shows your 3 most important findings in full.

SQL injection prevention checklist

  • Every query uses parameters or the ORM query builder. Zero string concatenation or interpolation into SQL.
  • Raw query methods ($queryRawUnsafe, raw(), find_by_sql, text()) are reviewed and use bound params.
  • Column names, table names and sort directions come from an allowlist.
  • Values read from your own database are treated as untrusted when reused in new queries (second-order).
  • NoSQL queries validate types and reject $ operators from user input.
  • The database user has least privilege; production does not connect as a superuser.
  • Raw database errors never reach the client.
  • CI runs SAST on every pull request, and regression tests cover injection payloads.
  • Dependencies (drivers, ORMs) are up to date and checked against known CVEs.

SQL injection is only one item in a bigger picture. See the OWASP Top 10 2025 for the other categories, what DevSecOps is for how to bake these checks into your pipeline, and what penetration testing is for how attackers chain SQLi with other flaws. When you are ready, find the vulnerabilities in your code before someone else does.