SQL injection is one of the oldest web vulnerabilities and, frustratingly, still one of the most common. It happens when an application builds a database query by gluing untrusted input straight into SQL text. If an attacker can smuggle SQL syntax through that input, they can read data they should never see, modify or delete records, bypass logins, and in the worst cases pivot to the underlying server. It remains in the OWASP Top 10 under Injection precisely because it is easy to introduce and cheap to exploit.
This article explains how a SQL injection attack works, walks through the main attack types with concrete examples, and then gives you a practical SQL injection prevention checklist that actually holds up in 2026. The goal is defensive: understand the attack well enough to shut it down. (Only ever test systems you own or are explicitly authorized to assess — unauthorized testing is illegal in most jurisdictions.)
Why SQL injection still matters
You would expect a bug this well understood to be extinct by now. It is not, for three reasons:
- Raw string queries are still everywhere — in legacy code, in quick internal tools, and in the "just this once" raw query dropped into an otherwise safe ORM.
- New surfaces keep appearing — GraphQL resolvers, serverless functions, reporting/search filters, and AI features that let a language model compose SQL from natural language all create fresh places to concatenate input.
- The payoff is enormous — a single injectable parameter can expose an entire customer database. Breaches traced back to SQL injection routinely leak millions of records.
How a SQL injection attack works
Almost every SQL injection starts the same way: the application constructs a query like this (shown here in PHP, but the flaw is language-agnostic):
// VULNERABLE — never do this
$id = $_GET['id'];
$sql = "SELECT * FROM news WHERE id = $id";
$rows = $db->query($sql);
The developer assumed id is a number. An attacker treats it as an injection point.
Step 1 — probe for the flaw
Send a value that breaks SQL syntax, such as a lone quote: ...?id=1'. If the page returns a database error (or behaves differently), the input is reaching the query unescaped. That single-quote test is the classic "is this injectable?" probe.
Step 2 — always-true conditions
The textbook payload appends a condition that is always true:
?id=1 OR 1=1
which turns the query into:
SELECT * FROM news WHERE id = 1 OR 1=1 -- returns every row
Because 1=1 is always true, the WHERE clause stops filtering and the whole table comes back. The same trick against a login query (... WHERE user='admin' AND pass='' OR '1'='1') can bypass authentication entirely.
Step 3 — UNION to steal from other tables
Once an attacker knows a query is injectable, UNION SELECT lets them append rows from a different table to the results. UNION requires matching column counts, so they first find how many columns the original query returns — incrementing ORDER BY 1, ORDER BY 2, … until it errors, or trying UNION SELECT 1,2,3,... until it succeeds. With the column count known, they read sensitive data:
SELECT * FROM news WHERE id = -1
UNION SELECT username, password_hash, 3, 4, 5 FROM users
The -1 discards the legitimate row so only the injected users data is displayed. This is in-band, UNION-based injection — the data comes straight back in the page.
The main attack types
- In-band (classic) — results or database errors are returned directly in the response. Includes error-based (leaking data through error messages) and UNION-based injection.
- Blind injection — the page shows no data or errors, but the attacker infers information one bit at a time:
- Boolean-based:
... AND 1=1(page loads normally) vs... AND 1=2(page differs) reveals true/false answers. - Time-based:
... AND SLEEP(5)— if the response is delayed, the injected condition was true. Slow but reliable, and it works even when nothing is displayed. - Out-of-band — the database is made to send data to an attacker-controlled server (e.g. via a DNS or HTTP callback) when direct and blind channels are closed.
- Second-order — malicious input is stored safely, then later concatenated unsafely by a different query. The dangerous moment is the read, not the write, which is why input filtering alone is not enough.
Attackers rarely do this by hand; tools like sqlmap automate detection and extraction across all of these types, which is exactly why partial defenses fail.
SQL injection prevention: the defenses that work
Good news: defending is now easier than attacking. The fixes below are ordered by importance — the first one alone stops the overwhelming majority of injection.
1. Parameterized queries (prepared statements) — the primary fix
Never build SQL by concatenating input. Use parameterized queries, where the SQL structure and the data travel to the database separately. The driver sends the query template first, then binds values that can never be interpreted as SQL — a quote inside a value stays a literal quote, not a statement terminator. This is the single most effective control.
PHP (PDO):
$stmt = $db->prepare("SELECT * FROM news WHERE id = ?");
$stmt->execute([$_GET['id']]);
$rows = $stmt->fetchAll();
Python (with any DB-API driver — note the driver does the binding, not Python string formatting):
cur.execute("SELECT * FROM news WHERE id = %s", (request.args["id"],))
Node.js (mysql2 / pg):
await pool.query("SELECT * FROM news WHERE id = ?", [req.query.id]);
Java (JDBC PreparedStatement):
PreparedStatement ps = conn.prepareStatement("SELECT * FROM news WHERE id = ?");
ps.setInt(1, id);
The critical rule: parameters are for values, not for identifiers. You cannot bind a table or column name or a keyword like ASC/DESC — if those must be dynamic, validate them against an explicit allowlist of permitted strings.
2. Use an ORM or query builder — but watch the raw queries
Modern ORMs and query builders (Laravel Eloquent, Django ORM, SQLAlchemy, Prisma, Hibernate, Entity Framework) generate parameterized SQL for you, which is why frameworks dramatically reduce injection. The catch: every one of them offers a raw-query escape hatch (DB::raw, .raw(), text(), string-built WHERE clauses). The moment you drop to raw SQL, you are back to rule 1 — bind your parameters.
3. Validate and constrain input
Defense in depth, not a substitute for parameterization. Cast and validate types ((int), schema validation), and prefer allowlists ("this value must be one of date, price, name") over blocklists that try to strip "bad" characters — blocklists are routinely bypassed. Type-casting a numeric ID, for instance, makes 1 OR 1=1 collapse to the harmless integer 1.
4. Enforce least privilege on the database account
The account your application connects with should have only the permissions it needs — typically SELECT/INSERT/UPDATE on specific tables, and rarely DROP, FILE, or admin rights. Then, even if an injection slips through, the blast radius is limited: the attacker cannot drop tables or read the whole schema. Never connect your app as root/sa.
5. Handle errors and secrets carefully
Return generic error pages to users and log the details server-side. Verbose database errors hand attackers a map (table names, column counts, driver versions) and turn blind injection into easy error-based injection.
6. Add a WAF and monitoring as backstops
A Web Application Firewall (cloud WAFs, ModSecurity) can block common injection payloads and buys time to patch, but it is a supplement, never the primary defense — WAF rules are bypassable. Pair it with logging and alerting on unusual query patterns, repeated errors, or spikes in data returned. For the related problem of automated tools hammering your endpoints, see anti-scraping protection.
7. Keep everything patched and test continuously
Update your database, drivers, ORM and framework — injection-adjacent bugs get fixed upstream. Fold automated scanning (SAST/DAST) into CI, and note that the same "separate code from data" principle applies to NoSQL injection (MongoDB operator injection) and command injection — validate and parameterize there too.
SQL injection prevention checklist
- [ ] All queries use parameterized statements — no string concatenation of input, anywhere.
- [ ] Raw/ORM-escape-hatch queries are audited and bound.
- [ ] Dynamic identifiers (table/column/sort) are checked against an allowlist.
- [ ] Input is type-validated (defense in depth).
- [ ] The app's DB account runs with least privilege.
- [ ] Database errors are not shown to users; details are logged server-side.
- [ ] A WAF and query-anomaly monitoring are in place as backstops.
- [ ] Database, drivers, ORM and framework are patched, with SAST/DAST in CI.
The bottom line
SQL injection persists not because it is hard to prevent, but because it is easy to reintroduce — one concatenated query is all it takes. The fix is disciplined and boring: treat user input as data, never as code, by using parameterized statements everywhere, backed by least-privilege database accounts, careful error handling, and monitoring. Get the first item on the checklist right across your whole codebase and you have closed the door on the overwhelming majority of injection attacks. For broader hardening, see our guides on web security and bot mitigation and protecting data from cyber theft.