SQL injection is a flaw where user input is inserted directly into a database query, letting an attacker change what that query does. Instead of supplying a value, they supply SQL, and the database executes it. The result can be reading data the attacker should never see, bypassing a login, or deleting records, all because code and data were never kept apart.
SQL injection is old, thoroughly documented and still routinely found, which tells you something: it persists not because it is hard to prevent but because a single overlooked query reintroduces it. Understanding the mechanism makes the fix obvious and permanent.
How it works
Consider a login that builds its query by pasting the username and password into a string:
-- Vulnerable: input concatenated into the query
query = "SELECT * FROM users WHERE name = '" + username + "' AND pass = '" + password + "'";
A normal username produces a normal query. But if the attacker enters this as the username:
' OR '1'='1' --
the query the database receives becomes:
SELECT * FROM users WHERE name = '' OR '1'='1' --' AND pass = '...'
The OR '1'='1' is always true, and -- comments out the password check. The database returns every user, and the application logs the attacker in, often as the first row, which is frequently an administrator. The input was meant to be a value; the database read it as logic.
What an attacker can do with it
- Read arbitrary data — dump user tables, password hashes, payment records, using
UNIONqueries to append attacker-chosen results. - Bypass authentication — as above, make the
WHEREclause always true. - Modify or destroy data — where the injected statement can write, alter balances or drop tables.
- Extract data blindly — even when no results are shown, infer data bit by bit from timing or true/false response differences (blind SQLi).
- Escalate — on some configurations, read files or run commands on the database host.
Some of the largest breaches on record began with a single injectable parameter. It is a critical-severity flaw whenever it is exploitable.
The blind variant deserves a closer look, because it is where SQL injection becomes patient and mechanical rather than dramatic. Suppose a page shows "user found" or "user not found" but never displays data. An attacker can still extract the entire database one true/false question at a time: "is the first character of the admin's password hash greater than m?" Each request narrows the answer, and automated tools turn thousands of such questions into a full dump in minutes. A time-based version works even when there is no visible difference at all, by asking the database to pause for a few seconds when a condition is true and measuring the response time. The absence of visible output is not protection; it only changes the technique.
It is also worth being clear that injection is not limited to classic relational databases. The same failure, mixing untrusted input into a command interpreter, appears as NoSQL injection against document stores, as LDAP injection against directory services, and as command injection when input reaches a shell. The syntax differs; the root cause and the mental model are identical. Learn to see the pattern once and you recognise it everywhere: wherever your code hands attacker-influenced text to something that parses text as instructions, you have a potential injection point.
The one fix that works: parameterised queries
The definitive defence is to never build queries by concatenation. Use parameterised queries (also called prepared statements), where the SQL structure is fixed and the values are passed separately, so the database always treats them as data. The same login, done safely:
-- Python (parameters passed separately, never interpolated)
cur.execute(
"SELECT * FROM users WHERE name = %s AND pass = %s",
(username, password),
)
// Node.js (parameterised)
db.query(
'SELECT * FROM users WHERE name = ? AND pass = ?',
[username, password],
);
Now the ' OR '1'='1 payload is searched for as a literal username, found nowhere, and the login fails as it should. The malicious input can never change the query's structure because it never touches the SQL string.
Defences that help but are not enough
| Control | What it does | Why it is not sufficient alone |
|---|---|---|
| Parameterised queries | Separates code from data | This is the actual fix |
| Input validation | Rejects clearly malformed input | Valid input can still be malicious |
| Least-privilege DB account | Limits blast radius | Reduces impact, not the flaw |
| Web application firewall | Blocks known payload patterns | Bypassable; a stopgap only |
| Stored procedures | Can parameterise | Still vulnerable if they concatenate internally |
Validation and least privilege are worthwhile in depth, but treating them as the fix is the mistake: only parameterisation removes the flaw. SQL injection is the archetypal injection risk in the OWASP Top 10, and it shares its root cause and fix pattern with cross-site scripting, keep code and data separate.
Two of these controls deserve a fair defence, because they are valuable even though they are not the fix. A least-privilege database account, one that can read the tables it needs and nothing more, cannot prevent injection, but it dramatically limits what a successful injection achieves: an attacker cannot drop tables the account cannot drop, or read a schema it cannot see. Keeping database error messages out of responses matters too, because verbose errors hand an attacker a map, exact column names, database version, query structure, that turns blind injection into a guided tour. Neither closes the hole, but both raise the cost of exploiting it and shrink the blast radius when something is missed. That is the essence of defence in depth: you fix the flaw with parameterisation, and you assume you will occasionally fail to, so you make failure survivable.
Where injection hides in a real codebase
If parameterisation is so simple, why is SQL injection still found? Because a single query, written under deadline pressure or copied from an old example, reintroduces it, and it only takes one. The riskiest queries are rarely the obvious login form, which everyone reviews; they are the reporting feature with a dynamic ORDER BY that cannot be parameterised the usual way, the search filter assembled from optional criteria, the admin tool written quickly for internal use, and the legacy module nobody has touched in years. Dynamic query construction, where the very structure of the SQL depends on user input such as a chosen sort column, is where teams most often fall back to string building. The answer there is an allowlist: map user input to a fixed set of known-safe column names in your code, never let the raw value reach the query. Injection persists not because the fix is hard but because the discipline has to be total.
Why an external scan rarely proves it
Confirming SQL injection means sending payloads, a single quote to provoke an error, timing probes for blind injection, and watching how the application responds. That is active scanning, and it can corrupt data or trip defences, so it is done only with authorisation, as explained in passive versus active scanning. A passive scanner such as Scan.now will not fire injection payloads; it reports the surrounding hygiene instead. This is a good example of the honest limits described in what a vulnerability scan is.
Verify what you can, fix what you must
You cannot outsource SQL injection prevention to a scanner, because the fix lives in your query code. What a scan does give you is the surrounding picture: whether error pages leak database details, whether transport and headers are sound. Run the website scanner for that context, and treat this guide's real fix, parameterise every query, no exceptions, as non-negotiable. It is part of the broader website security hub, but it is ultimately a discipline in code review, not a setting to toggle.
The encouraging truth about SQL injection is that it is one of the few serious web vulnerabilities that is genuinely solved at the level of technique. Unlike access-control flaws, which demand judgement about who should see what, or design flaws, which require foresight, injection has a mechanical answer that works every time it is applied: separate the query's structure from its data. A team that adopts parameterised queries as a hard rule, enforced in code review and, where possible, by a linter that flags string-built SQL, can drive its injection rate to essentially zero and keep it there. The vulnerability persists across the industry not because anyone lacks the fix but because the fix has to be applied to every query, forever, including the ones written in a hurry and the ones inherited from years ago. Make parameterisation the default your framework reaches for, make raw SQL the exception that draws a reviewer's eye, and you convert a perennial critical risk into a problem you have simply designed out. Few security wins are that clean; take this one.