What is SQL Injection? The Attack That's Been Destroying Databases Since 1998
In 2008, attackers used SQL injection to breach Heartland Payment Systems, a payment processing company. They stole 130 million credit and debit card numbers. The breach cost Heartland over $140 million in settlements and fines. The CEO described it as an "international cybercrime ring." The technical execution was a SQL injection vulnerability in their web application.
In 2017, Equifax lost the personal data of 147 million Americans. While the initial vector was an unpatched Apache Struts vulnerability, the attackers moved laterally through systems using SQL injection and other database attacks to exfiltrate data.
SQL injection has been on the OWASP Top 10 list of critical web application security risks since the list was created in 2003. It has held the top spot for most of that time. Despite being a known, documented, entirely preventable vulnerability class for over 25 years, it remains one of the most exploited attack types in production applications today.
The reason it persists is simple: it's a consequence of a fundamental architectural mistake that's easy to make and easy to miss.
What SQL Injection Is
SQL injection (SQLi) is a code injection attack where an attacker inserts malicious SQL code into input fields that the application uses to build database queries. If the application constructs queries by concatenating strings with user input, the attacker can modify the query's logic, bypass authentication, read data they shouldn't have access to, modify or delete records, or execute commands on the database server.
The vulnerability exists at the boundary between your application code and your database. The application accepts input from users and builds SQL queries from it. If it builds those queries by stitching strings together, user input becomes part of the query syntax — and the database parser cannot distinguish between intended SQL and injected SQL.
How Queries Get Broken
Start with a simple login form. The application takes a username and password and checks the database:
// PHP — classic vulnerable pattern
$username = $_POST['username'];
$password = $_POST['password'];
$query = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
$result = $db->query($query);
if ($result->num_rows > 0) {
// logged in
}
With legitimate input — username alice, password correct_password — the query is:
SELECT * FROM users WHERE username = 'alice' AND password = 'correct_password'
Now an attacker enters the username ' OR '1'='1' -- and any password:
SELECT * FROM users WHERE username = '' OR '1'='1' --' AND password = 'anything'
The -- is a SQL comment — it discards everything after it. The condition '1'='1' is always true. The query now asks: "give me a user where the username is empty OR 1=1" — which returns every row in the users table. The first row returned is probably an admin account. The attacker is now logged in.
The database received syntactically valid SQL. It executed exactly what it was asked to execute. The problem is that the query it executed is not the query the developer intended.
Attack Types
Classic (In-Band) SQLi — the attack results are returned directly in the application response. The attacker can see the output of their injected queries in the page content.
The UNION attack extracts data from other tables by appending a UNION SELECT:
-- Original query intention:
SELECT product_name, price FROM products WHERE id = 5
-- Attacker input: 5 UNION SELECT username, password FROM users--
-- Resulting query:
SELECT product_name, price FROM products WHERE id = 5
UNION SELECT username, password FROM users--
The UNION SELECT must have the same number of columns as the original query. Attackers first determine column count by injecting ORDER BY 1, ORDER BY 2, etc., until an error occurs. Then they match column types. Then they extract data from any table they can name.
To find table names in MySQL:
5 UNION SELECT table_name, table_schema FROM information_schema.tables--
To find column names in a specific table:
5 UNION SELECT column_name, data_type FROM information_schema.columns WHERE table_name='users'--
This is how an attacker who finds one SQL injection vulnerability can read any data in the entire database. They don't need to know the schema in advance — the database's own information schema tables describe it to them.
Blind SQLi — the application doesn't return query results directly, but the attacker can infer information from the application's behavior.
Boolean-based blind: The attacker injects conditions that are either true or false and observes different responses:
-- If users table exists, page loads normally
5 AND (SELECT COUNT(*) FROM users) > 0 --
-- If first character of first username is 'a', page loads
5 AND SUBSTRING((SELECT username FROM users LIMIT 1), 1, 1) = 'a' --
-- Binary search: if character is alphabetically before 'm', page loads
5 AND ASCII(SUBSTRING((SELECT username FROM users LIMIT 1), 1, 1)) < 109 --
By binary-searching through character values, an attacker can extract the entire database contents one character at a time, making a separate HTTP request for each bit of information. This is slow but effective. Automated tools like sqlmap do this at machine speed.
Time-based blind: When the application returns identical responses regardless of query success or failure, attackers use deliberate delays:
-- MySQL: if admin user exists, wait 5 seconds before responding
5 AND IF((SELECT COUNT(*) FROM users WHERE username='admin') > 0, SLEEP(5), 0) --
-- PostgreSQL equivalent
5; SELECT CASE WHEN (SELECT COUNT(*) FROM users WHERE username='admin') > 0 THEN pg_sleep(5) END --
If the response takes 5 seconds, the condition is true. False if immediate. Binary search through character values with time delays. Automated tools make this practical despite the slowness.
Out-of-Band SQLi — the attacker causes the database to make network connections to an attacker-controlled server, exfiltrating data out of band. Used when other techniques fail due to network restrictions or application behavior. Requires specific database functions (UTL_HTTP in Oracle, LOAD_FILE/INTO OUTFILE in MySQL with FILE privileges, xp_cmdshell in SQL Server).
Second-Order SQLi — user input is stored safely in the database initially, but later retrieved and used unsafely in a subsequent query. The application correctly parameterizes the INSERT, but then pulls the stored value and uses it in another query with string concatenation. The injection lives dormant in the database until it's triggered by a different operation.
# Vulnerable pattern even with parameterized INSERT
# Stage 1: safe insertion
cursor.execute("INSERT INTO users (username) VALUES (%s)", (username,))
# Stage 2: later, another function retrieves and uses the stored username unsafely
cursor.execute("SELECT * FROM orders WHERE customer = '" + stored_username + "'")
If the stored username was admin'--, the second query becomes SELECT * FROM orders WHERE customer = 'admin'--' — a valid injection. Second-order SQLi is harder to find because the storage and exploitation happen in different code paths.
What Attackers Can Do With SQL Injection
The blast radius depends on the database configuration and what data exists:
Read any data the database user has access to. Username and password hashes. Email addresses. Credit card numbers. Personal information. Private messages. Proprietary business data. Session tokens. API keys stored in the database.
Bypass authentication. The classic ' OR '1'='1 login bypass. Even without knowing any credentials, an attacker can often log in as any user.
Modify or delete data. Update records, delete tables, change account information. An attacker who can inject into an UPDATE statement can modify any row they can reach.
Execute OS commands (in some configurations). SQL Server's xp_cmdshell stored procedure executes operating system commands directly. MySQL's INTO OUTFILE writes files to the filesystem. Oracle's UTL_FILE package reads and writes files. If the database user has the appropriate privileges, SQL injection becomes a foothold for full server compromise.
Exfiltrate the entire database. With automation, a single SQL injection vulnerability can leak an entire database in minutes.
Escalate to other systems. Database credentials often reuse passwords. The database server is often on the internal network. Database connection strings may contain credentials to other services.
Real Examples From Production Breaches
Sony Pictures (2011) — LulzSec stole personal information on over a million users from Sony Pictures' database. The method was SQL injection. The database contained unencrypted passwords. Sony's response at the time acknowledged SQL injection was the vector.
TalkTalk (2015) — A 17-year-old used SQL injection to breach TalkTalk, a UK telecom, stealing 157,000 customers' personal data. The vulnerability was in a legacy web application. TalkTalk received a £400,000 fine from the ICO.
Verizon DBIR consistently reports injection as a top action type in confirmed data breaches. Year after year. Despite being a known and preventable vulnerability class.
In the vibe coding security analysis we ran, Veracode's research found that AI models fail to generate secure code for SQL injection 20% of the time across coding tasks. The Cloud Security Alliance found that LLMs trained on GitHub data — which contains vast amounts of legacy code with SQL injection vulnerabilities — reproduce those same vulnerable patterns. When you accept AI-generated code without auditing it, you may be shipping 2005-era vulnerabilities with 2026-era confidence.
Why Injection Keeps Happening
If this is preventable, why does it keep appearing in production code?
Legacy codebases. A system built in 2005 with PHP's old mysql_ functions (which we discussed in What is PHP) might have hundreds of vulnerable query patterns scattered throughout. Fixing all of them requires finding all of them, which requires thorough code review and testing. Large codebases accumulate technical debt faster than it gets paid down.
Copy-paste from vulnerable tutorials. Bad examples propagate. W3Schools was showing vulnerable PHP database examples for years. Stack Overflow answers from 2008 with vulnerable patterns still get upvoted and referenced. A developer who learns from bad examples builds bad habits.
AI-generated code. The model generates code that works. "Works" and "secure" are different things. AI coding tools optimize for functionality, not security. Unless the developer specifically audits the generated query-building code, SQL injection vulnerabilities ship. The pattern is exactly what we documented in the Lovable security deep-dive.
ORMs that allow raw queries. Most modern ORMs protect against SQL injection for standard operations. But every ORM has escape hatches for raw SQL — and developers who use them without parameterization reintroduce the vulnerability. Django has raw() and extra(). SQLAlchemy has text(). Sequelize has QueryTypes.RAW. These are necessary features that require careful use.
Pressure and time. Security review takes time. Deadlines don't care about security review. Code that hasn't been audited ships.
How to Prevent a SQL Injection
There is one primary defense and it works completely: parameterized queries (prepared statements).
A parameterized query separates the SQL structure from the data. The query is compiled and the database's query planner analyzes it before any user data is inserted. When the data is provided, it's treated as a value, not as SQL syntax. The database cannot be confused about where code ends and data begins.
Python with psycopg2 (PostgreSQL):
# VULNERABLE — string interpolation
cursor.execute(f"SELECT * FROM users WHERE username = '{username}'")
# SAFE — parameterized
cursor.execute("SELECT * FROM users WHERE username = %s", (username,))
Python with SQLAlchemy:
# VULNERABLE
result = db.execute(f"SELECT * FROM users WHERE id = {user_id}")
# SAFE — SQLAlchemy ORM (handles parameterization automatically)
user = db.query(User).filter(User.id == user_id).first()
# SAFE — raw SQL with text() and bound parameters
from sqlalchemy import text
result = db.execute(text("SELECT * FROM users WHERE id = :id"), {"id": user_id})
Node.js with node-postgres:
// VULNERABLE
const result = await pool.query(`SELECT * FROM users WHERE email = '${email}'`);
// SAFE — parameterized with $1 placeholder
const result = await pool.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
PHP with PDO:
// VULNERABLE
$result = $pdo->query("SELECT * FROM users WHERE username = '$username'");
// SAFE — prepared statement
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = :username");
$stmt->execute([':username' => $username]);
$user = $stmt->fetch();
Java with PreparedStatement:
// VULNERABLE
String query = "SELECT * FROM users WHERE username = '" + username + "'";
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(query);
// SAFE
String query = "SELECT * FROM users WHERE username = ?";
PreparedStatement pstmt = conn.prepareStatement(query);
pstmt.setString(1, username);
ResultSet rs = pstmt.executeQuery();
The ? or :param or $1 placeholder is not PHP string interpolation or JavaScript template literals. It's a parameter marker that the database driver handles separately from the query text. The driver sends the query structure and the parameter values to the database in separate packets. The database's parser sees them as distinct — one is code, one is data — and it is structurally impossible for the data to be interpreted as code.
Secondary defenses — these don't replace parameterized queries but provide additional protection:
Stored procedures — SQL logic lives in the database, not in application code. When called correctly with parameters, they prevent injection. But stored procedures can themselves be vulnerable if they use dynamic SQL internally.
Input validation — if a field should contain only digits, reject anything else. This isn't a primary defense against SQL injection (parameterized queries are) but it reduces attack surface and catches obvious malformed input.
Principle of least privilege — the database user the application connects with should have only the permissions it needs. A read-only reporting query doesn't need INSERT, UPDATE, or DELETE. A web application user shouldn't have DROP TABLE privileges. When injection occurs against a least-privilege account, the blast radius is smaller.
Web Application Firewalls (WAFs) — inspect HTTP traffic and block requests that match SQL injection patterns. Useful as a layer of defense but not a substitute for parameterized queries. WAFs have false positives and can be bypassed by sufficiently obfuscated injection strings.
Error handling — never expose database error messages to users. Detailed error messages tell attackers about the database schema, query structure, and server configuration. Log errors internally, return generic messages externally.
Finding a SQL Injection in Your Code
Manual review: Search your codebase for patterns where query strings are built with concatenation:
# Find potential SQL injection in Python
grep -rn "execute(f\"" ./src/
grep -rn "execute(\".*%" ./src/ # old-style string formatting in queries
grep -rn '+ "' ./src/ | grep -i "select\|insert\|update\|delete"
Any query that includes user-controlled variables through string operations is suspect. The review question: is every piece of user input in this query separated from the query structure via parameterization?
Static analysis tools: Semgrep has SQL injection rules. Bandit (Python) flags common injection patterns. GitHub Advanced Security's CodeQL can detect unsanitized data flowing into database queries. These catch many cases automatically and should be part of any CI pipeline.
Dynamic testing: sqlmap is the standard automated SQL injection scanner. Given a URL with parameters, it systematically tests for injection vulnerabilities:
# Test a login endpoint (in a test environment you own)
sqlmap -u "http://testsite.com/login" --data="username=test&password=test" \
--level=3 --risk=2 --batch
# Test URL parameters
sqlmap -u "http://testsite.com/products?id=1" --dbs
sqlmap identifies injection points, determines the injection type, and can extract the entire database automatically. If you can run sqlmap against your application and it finds nothing, that's meaningful assurance. If it finds something, you need to fix it before an attacker runs it.
Penetration testing: For applications handling sensitive data, having a professional penetration tester attempt SQL injection (and other attacks) against your application before launch or on a regular cadence is worth the cost.
My Simple Rule
Every query that touches user input must use parameterized queries. Not most queries. Not queries you think are sensitive. Every query.
There is no such thing as "safe" user input for SQL purposes. Input validation can be bypassed. Escaping functions can be misconfigured. Encoding layers can be bypassed with carefully crafted input. Character sets can be manipulated to slip past blacklist filters.
Parameterization is architecturally safe because it operates at the database protocol level. The user input is never parsed as SQL syntax. There is no edge case to exploit, no character to escape, no bypass vector. You cannot inject SQL through a parameter placeholder.
This one principle, applied consistently, eliminates an entire category of vulnerability that has caused billions of dollars in damage and exposed hundreds of millions of people's data.
The Equifax breach that started this article could have been avoided. The Sony Pictures breach could have been avoided. The TalkTalk breach could have been avoided. In almost every case, the vulnerability was a developer building queries with string concatenation instead of parameterized statements. The fix is five characters: replace f"...{var}..." with "... %s ..." and add the variable as a parameter.
That's it. That's the fix. The attack is 25 years old and still happening because developers keep writing queries the wrong way.
Don't be that developer.