4 min read
On this page

SQL Injection

SQL injection is the most dangerous vulnerability that is also the most preventable. It has topped the OWASP Top 10 for over a decade. The fix — parameterized queries — has been available since the 1990s. Yet SQL injection still appears in production applications because developers concatenate user input into queries instead of using parameters.

How SQL Injection Works

SQL injection occurs when user-supplied input becomes part of a SQL query without proper separation between code and data.

// Vulnerable code (pseudocode)
query = "SELECT * FROM users WHERE username = '" + userInput + "'"

// Normal input: alice
// Resulting query: SELECT * FROM users WHERE username = 'alice'

// Malicious input: ' OR '1'='1
// Resulting query: SELECT * FROM users WHERE username = '' OR '1'='1'
// This returns every user in the table

The attacker escapes out of the string context and injects their own SQL logic. The database cannot distinguish between the developer's intended query and the attacker's addition because they are all just SQL.

Authentication Bypass

// Login query
query = "SELECT * FROM users WHERE username = '" + user + "' AND password = '" + pass + "'"

// Attacker input for username: admin' --
// Resulting query: SELECT * FROM users WHERE username = 'admin' --' AND password = ''
// The -- comments out the password check
// Attacker logs in as admin without knowing the password

Data Exfiltration

// Product search
query = "SELECT name, price FROM products WHERE category = '" + category + "'"

// Attacker input: ' UNION SELECT username, password FROM users --
// Resulting query:
// SELECT name, price FROM products WHERE category = ''
// UNION SELECT username, password FROM users --'
// Returns all usernames and passwords alongside product results

Data Destruction

// Attacker input: '; DROP TABLE users; --
// Resulting query: SELECT * FROM users WHERE username = ''; DROP TABLE users; --'
// Deletes the entire users table

The Fix: Parameterized Queries

Parameterized queries (also called prepared statements) separate the SQL structure from the data. The database engine receives the query template and the data separately. The data can never be interpreted as SQL code.

// Parameterized query (pseudocode)
query = "SELECT * FROM users WHERE username = ? AND password = ?"
execute(query, [userInput, passInput])

// The database sees:
// Template: SELECT * FROM users WHERE username = ? AND password = ?
// Parameter 1: "admin' --"  (treated as a literal string, not SQL)
// Parameter 2: ""           (treated as a literal string)

// No injection possible. The ' and -- are just characters in a string.

Every major language and framework supports parameterized queries:

// Node.js with pg
const result = await client.query(
  'SELECT * FROM users WHERE email = $1',
  [email]
)

// Python with psycopg2
cursor.execute(
  "SELECT * FROM users WHERE email = %s",
  (email,)
)

// Java with PreparedStatement
PreparedStatement stmt = conn.prepareStatement(
  "SELECT * FROM users WHERE email = ?"
);
stmt.setString(1, email);

// Go with database/sql
row := db.QueryRow(
  "SELECT * FROM users WHERE email = $1",
  email,
)

There is no performance penalty. Prepared statements are often faster because the database can cache the query plan.

ORMs: Mostly Safe, Not Completely

Object-Relational Mappers like Prisma, SQLAlchemy, Django ORM, and ActiveRecord generate parameterized queries by default. Using the ORM's query builder is generally safe.

// Safe — ORM generates parameterized query
User.findOne({ where: { email: userInput } })

// Safe — Django ORM
User.objects.filter(email=user_input)

But ORMs also allow raw queries, and those are just as dangerous as manual string concatenation:

// Dangerous — raw query with string interpolation
sequelize.query(`SELECT * FROM users WHERE email = '${userInput}'`)

// Safe — raw query with parameter binding
sequelize.query('SELECT * FROM users WHERE email = ?', {
  replacements: [userInput]
})

The rule: even when using an ORM, never interpolate user input into raw queries.

Blind SQL Injection

Not all SQL injection returns visible data. Blind SQL injection extracts information by asking the database true/false questions or measuring response times.

Boolean-Based Blind Injection

// The attacker cannot see query results directly
// But the page behaves differently for valid vs. invalid queries

// Request 1: id=1 AND 1=1  -> Page loads normally (true condition)
// Request 2: id=1 AND 1=2  -> Page shows error or blank (false condition)

// Now the attacker can ask questions:
// id=1 AND (SELECT LENGTH(password) FROM users WHERE username='admin') = 8
// If page loads normally, the admin password is 8 characters

// id=1 AND (SELECT SUBSTRING(password,1,1) FROM users WHERE username='admin') = 'a'
// If page loads normally, the first character of the password is 'a'

// Character by character, the attacker extracts the entire password

Time-Based Blind Injection

// The attacker measures response time

// id=1; IF (SELECT COUNT(*) FROM users) > 0 WAITFOR DELAY '0:0:5'
// If response takes 5 seconds, there are users in the table

// id=1; IF (SUBSTRING((SELECT password FROM users WHERE username='admin'),1,1)='a')
//   WAITFOR DELAY '0:0:5'
// If response takes 5 seconds, the first character is 'a'

Blind injection is slower but just as dangerous. Automated tools like sqlmap can extract entire databases character by character.

Second-Order SQL Injection

Second-order injection is particularly insidious because the malicious input is not used immediately. It is stored safely, then later included in a different query without parameterization.

// Step 1: User registers with username: admin'--
// The registration query is parameterized, so the username is stored safely
INSERT INTO users (username, email) VALUES ($1, $2)
// Username "admin'--" is stored in the database as-is

// Step 2: Later, an admin tool builds a query using data from the database
query = "SELECT * FROM audit_log WHERE username = '" + row.username + "'"
// The developer trusts database data because "it already passed validation"
// Resulting query: SELECT * FROM audit_log WHERE username = 'admin'--'
// Injection happens at read time, not write time

The lesson: treat data from your own database with the same suspicion as user input. Always use parameterized queries, even when the data source is "trusted."

Real-World SQL Injection Breaches

2008 — Heartland Payment Systems
- SQL injection led to the theft of 130 million credit card numbers
- Largest data breach at the time
- Company paid over $140 million in settlements
- The attack started with a single SQL injection vulnerability in a web form

2011 — Sony PlayStation Network
- SQL injection was part of the attack that exposed 77 million accounts
- PSN was offline for 23 days
- Sony estimated $171 million in costs

2015 — TalkTalk
- SQL injection on the website exposed 157,000 customers' personal data
- Including 15,600 bank account numbers
- CEO received a personal fine
- CVE-2015-7755 (related infrastructure vulnerability)
- The company's stock dropped 12%

2019 — Fortnite (Epic Games)
- SQL injection vulnerability could have exposed 200 million player accounts
- Discovered by Check Point Research before exploitation
- Demonstrated that even well-funded companies miss basic injection flaws

How to Test for SQL Injection

Manual Testing

// Inject these payloads into every input field, URL parameter, and header:
'
"
' OR '1'='1
' OR '1'='1' --
' UNION SELECT NULL --
1; WAITFOR DELAY '0:0:5' --

// Watch for:
- Database error messages (especially ones mentioning SQL syntax)
- Changed behavior between normal and injected inputs
- Delayed responses (time-based injection)
- Different results for always-true vs. always-false conditions

Automated Testing

// sqlmap — automated SQL injection detection and exploitation
sqlmap -u "http://target.com/page?id=1" --batch --dbs

// OWASP ZAP — web application security scanner
// Run active scan against all endpoints

// Static analysis — catch injection in code before deployment
// Tools: Semgrep, SonarQube, CodeQL
// Rule: flag any string concatenation in SQL queries

Code Review Checklist

1. Search for string concatenation in SQL: + , f-strings, format(), template literals
2. Search for raw query methods in your ORM
3. Check stored procedures for dynamic SQL (EXEC, sp_executesql without parameters)
4. Check if the application uses a query builder or raw strings
5. Verify that every database query uses parameterized inputs

Stored Procedures Are Not Automatically Safe

A common misconception is that stored procedures prevent SQL injection. They do not if they use dynamic SQL internally.

// Vulnerable stored procedure
CREATE PROCEDURE GetUser @username NVARCHAR(100)
AS
  EXEC('SELECT * FROM users WHERE username = ''' + @username + '''')

// Safe stored procedure
CREATE PROCEDURE GetUser @username NVARCHAR(100)
AS
  SELECT * FROM users WHERE username = @username

The same rule applies: never concatenate input into SQL, whether in application code or database code.

Common Pitfalls

  • Blocklist-based filtering. Trying to strip or block dangerous characters like quotes and semicolons. Attackers have dozens of bypass techniques (encoding, double-encoding, alternate syntax). Parameterized queries are the only reliable fix.

  • Trusting "internal" data. Data from your own database, from another microservice, or from a config file can still contain malicious payloads (second-order injection). Always parameterize.

  • Escaping instead of parameterizing. Manual escaping functions are error-prone and database-specific. Use parameterized queries. There is no reason not to.

  • Forgetting non-obvious injection points. SQL injection can happen through HTTP headers (User-Agent, Referer), cookies, file uploads (CSV imports), and any input that eventually reaches a query.

  • Assuming ORMs are bulletproof. ORMs prevent most injection, but raw queries, custom fragments, and ORDER BY clauses often bypass ORM protections.

  • Ignoring error messages in production. Detailed database errors help attackers understand your schema. Always return generic error messages and log details server-side.

Key Takeaways

  • SQL injection happens when user input becomes part of a SQL query. The input is interpreted as code instead of data.
  • Parameterized queries are the fix. They separate query structure from data, making injection impossible. Every language supports them.
  • ORMs generate safe queries by default, but raw query methods are still vulnerable. Never interpolate user input into raw queries.
  • Blind SQL injection extracts data without visible output by using boolean conditions or time delays. Automated tools make this trivial.
  • Second-order injection stores a malicious payload safely, then triggers it later when the data is used in a different query. Treat all data as untrusted.
  • SQL injection has caused some of the largest data breaches in history, yet it remains completely preventable with parameterized queries.