SQL injection has been a “known” bug class since the late 1990s, it’s had its own OWASP category for as long as OWASP has published one, and it still shows up in fresh CVEs and bug bounty reports every single week. That’s not because it’s hard to understand - it might be the most explainable vulnerability class there is. It’s because the fix requires a habit, not a patch, and habits are exactly the thing that slips under deadline pressure.
What SQL injection actually is
A SQL injection happens when user-controlled input gets concatenated directly into a SQL query string instead of being passed as data. The database can’t tell the difference between “a string the app meant as data” and “a string the app meant as code” - it just executes whatever text arrives as the query. If an attacker can shape that text, they can shape the query.
const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
db.query(query);
Send a username of admin' -- and the query becomes:
SELECT * FROM users WHERE username = 'admin' --' AND password = '...'
-- starts a SQL comment, so everything after it, including the password
check, is discarded. The query now just asks “does a user named admin
exist?” and logs the attacker in as them. No password guessed, no hash
cracked; the authentication check was simply edited out by the input.
Why it’s still everywhere
- String concatenation reads as the obvious solution. Building a query by gluing strings together is the first thing that works when you’re writing a data access layer, and it looks correct in every manual test where the input is a normal username or search term.
- It hides in “internal” and “trusted” inputs too, not just public login
forms - admin search filters, CSV import columns,
Sort bydropdown values reflected into anORDER BYclause, even HTTP headers logged into a query. Any string that reaches a query unparameterized is a candidate, regardless of how “trusted” the source seems. - ORMs reduce it, but don’t eliminate it. Most ORM query builders
parameterize by default - but nearly all of them also expose a raw/literal
escape hatch (
.rawQuery(),.whereRaw(), a template-literal helper) for the query that’s awkward to express through the builder, and that’s exactly where a hand-built string tends to sneak back in. - It compounds with legacy code. Old queries built before parameterized patterns were standard practice at a given team rarely get revisited unless something forces the issue - and “it’s worked for years” is not evidence it’s safe, just evidence it hasn’t been tested by someone who knew to try.
A vulnerable example
A “search products by name” endpoint - unremarkable, ships in nearly every CRUD app:
// GET /api/products?q=laptop
app.get('/api/products', async (req, res) => {
const q = req.query.q;
const [rows] = await db.query(
`SELECT id, name, price FROM products WHERE name LIKE '%${q}%'`
);
res.json(rows);
});
A normal search for laptop works fine and looks fine in testing. But
q flows straight into the query string. A request like:
GET /api/products?q=x' UNION SELECT username, password, 1 FROM users --
turns the intended product search into a query that also selects usernames and password hashes out of an entirely different table, returned in the same JSON response the frontend already trusts and renders.
The fix is to never let the database receive user input as part of the query text - pass it as a bound parameter instead, so the database engine itself keeps code and data separate:
app.get('/api/products', async (req, res) => {
const q = req.query.q;
const [rows] = await db.query(
'SELECT id, name, price FROM products WHERE name LIKE ?',
[`%${q}%`]
);
res.json(rows);
});
The query structure (?) is fixed at write time; q is handed to the
driver separately and can never be interpreted as SQL syntax, no matter what
characters it contains. This is the entire fix - not input sanitization, not
a blocklist of dangerous characters, just refusing to build queries out of
strings in the first place.
Where it turns up beyond login forms
- Search and filter parameters - the classic case above, but also filter chips, autocomplete, faceted search
- Sort/order parameters - a
?sort=namevalue concatenated straight into anORDER BYclause, which parameterized placeholders can’t cover directly since column/direction names aren’t values (needs an allowlist - see below) - Report and export generators - internal tooling that builds a query from several optional filters, often the least-reviewed code in a codebase because “only admins use it”
- Second-order injection - a value gets safely stored (parameterized on the way in), then later read back and concatenated unsafely into a different query, which passes every test that only checks the write path
- NoSQL and other query languages - the same root cause (query built
from unsanitized input) shows up as MongoDB operator injection
(
{"$gt": ""}smuggled through a JSON body), LDAP injection, and raw shell/command construction; the fix is the same principle even where the syntax and the name of the bug class differ
How it’s found
- Throw SQL metacharacters at every input, not just form fields:
single quotes,
--,;,UNION SELECT. Watch for a database error leaking into the response, a change in row count, or a timing difference from a boolean/time-based blind payload. - Try it in headers and cookies too, if the app logs or queries against
them (
User-Agent,X-Forwarded-Forare classic overlooked injection points in logging pipelines). - Check error responses carefully. A verbose stack trace or raw database error message on a malformed request is often the first signal, confirming the input reached the query engine unparsed before you’ve proven anything is exploitable yet.
- Escalate from confirmation to impact carefully and only within scope - going from “this input breaks the query” to “this input reads another table” is exactly the boundary a bug bounty program’s rules of engagement exist to define; confirming impact should never mean running destructive statements against a live database.
Real-world impact
This bug class has shown up behind some of the largest breaches on record: retailer point-of-sale networks compromised via injected web forms, and still-recurring reports against government and education portals where a single unsanitized search or login field exposed an entire user database. The pattern across nearly all of them is the same: one query built from a string, discovered by someone who knew to try a single quote.
Fixing it for real
- Use parameterized queries / prepared statements everywhere, full
stop - the driver-level placeholder (
?,$1, named parameters, whichever your database client uses), never string interpolation, for every query that includes any input the caller influences. - Allowlist, don’t concatenate, for identifiers. Column names, table
names, and sort directions can’t go through a bound parameter (they’re
part of the query structure, not a value) - validate them against a fixed
set of known-safe options instead (
const SORT_COLUMNS = ['name', 'price', 'created_at']). - Treat ORM raw-query escape hatches as a code-review flag. They’re sometimes necessary, but every use is worth a second look for whether the interpolated part includes user input.
- Apply least privilege to the database account the application uses - a web app’s DB user shouldn’t be able to read tables it never queries or run admin functions, so a successful injection has a lower ceiling.
- Use a WAF and query-logging as defense-in-depth, not as the fix - both catch known attack signatures and unusual query shapes, but a novel payload or an internal tool without WAF coverage will still get through if parameterization isn’t the actual foundation.
- Test with adversarial input as part of normal QA, not just a pre-release security pass - a single quote in every text field is a cheap habit that catches this class before it ships.
The takeaway
SQL injection persists not because it’s mysterious but because “build the query with a template string” is faster to write than “build it with bound parameters” and looks identical in every test that doesn’t try to break it. The fix isn’t a smarter filter - it’s a structural one: user input never becomes part of the query text, full stop, and a whole category of attack loses its foothold before it starts.
Read the IDOR write-up next if you haven’t - a different failure mode, same lesson: the bug lives in what the server trusted, not in what the attacker sent.