A practical guide to stopping SQL injection with parameterized queries, ORM safety, least privilege, and layered defenses that actually hold up.
SQL injection has been at or near the top of every vulnerability list for two decades, and it still lands in production regularly. It is not an exotic bug. It shows up when an application builds SQL by gluing user input into a query string. The database has no way to know that the "data" you handed it was meant to be data and not commands, so it happily runs whatever you assembled.
Consider a login query built with string concatenation:
# VULNERABLE
username = request.form["username"]
password = request.form["password"]
query = "SELECT * FROM users WHERE username = '" + username + \
"' AND password = '" + password + "'"
db.execute(query)
An attacker submits admin' -- as the username. The query becomes:
SELECT * FROM users WHERE username = 'admin' --' AND password = '...'
The -- comments out the password check. They are now logged in as admin without knowing the password. That is the classic auth bypass.
UNION-based extraction is the next step. If a search endpoint concatenates input into a SELECT, an attacker appends ' UNION SELECT credit_card, cvv FROM payments -- and reads data from tables the endpoint was never meant to touch. Same root cause: input crossing the line from data into query structure.
The one durable fix is to stop building queries by string manipulation. Use parameterized queries (also called prepared statements). You send the query text and the parameter values to the driver separately. The database compiles the query structure first, then binds your values as pure data. There is no parse step where admin' -- can become syntax.
Python with a real placeholder:
# FIXED
query = "SELECT * FROM users WHERE username = %s AND password_hash = %s"
db.execute(query, (username, password_hash))
The %s here is a driver placeholder, not Python string formatting. Never use f-strings, .format(), or + to inject the value.
Node.js with pg:
// VULNERABLE
const q = `SELECT * FROM users WHERE email = '${req.body.email}'`;
await client.query(q);
// FIXED
const q = "SELECT * FROM users WHERE email = $1";
await client.query(q, [req.body.email]);
Java with JDBC follows the same shape:
// FIXED
String sql = "SELECT * FROM users WHERE email = ?";
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1, email);
ResultSet rs = ps.executeQuery();
The pattern is identical across every stack: placeholders in the query, values passed alongside. Learn it once and apply it everywhere.
ORMs and query builders parameterize by default. When you write User.objects.filter(email=email) in Django, db.query(User).filter_by(email=email) in SQLAlchemy, or prisma.user.findMany({ where: { email } }), the framework generates a parameterized statement for you. Used normally, an ORM makes injection hard.
The trouble starts when developers drop to raw SQL and rebuild the vulnerability by hand:
# VULNERABLE - raw query with interpolation
User.objects.raw("SELECT * FROM users WHERE name = '%s'" % name)
# FIXED - raw query with bound params
User.objects.raw("SELECT * FROM users WHERE name = %s", [name])
Same story in SQLAlchemy. text("... WHERE id = " + user_id) is exploitable; text("... WHERE id = :id") with .bindparams(id=user_id) is safe. An ORM is not a magic shield. It protects you exactly as long as you let it build the SQL. Any raw, execute, literal, or .query(rawString) escape hatch puts the responsibility back on you.
Placeholders only work for values, not for identifiers. You cannot parameterize a table name, a column name, or the direction of an ORDER BY. So dynamic sorting like ORDER BY {user_column} cannot be fixed with a bind variable.
The answer is an allowlist. Map untrusted input to a fixed set of known-good identifiers you control:
SORT_COLUMNS = {"date": "created_at", "name": "full_name", "price": "amount"}
column = SORT_COLUMNS.get(request.args.get("sort"), "created_at")
query = f"SELECT * FROM orders ORDER BY {column} ASC"
The user picks a key, never the SQL text. If the key is not in the map, you fall back to a safe default. Never pass raw identifier strings from the client into a query.
Stored procedures are often sold as an injection defense, and they can help because they encourage a fixed interface. But a stored procedure that builds dynamic SQL internally with concatenation is just as vulnerable as application code doing the same thing. EXEC('SELECT * FROM ' + @table) inside a proc is a live injection point. Stored procedures are safe only when their parameters are used as parameters, not spliced into dynamic SQL.
Assume some injection will slip through and limit the blast radius. The account your web application connects with should not be able to do more than the application needs. A read-heavy API should connect as a user with SELECT and scoped INSERT/UPDATE, not as a superuser. It should not own DROP TABLE, should not read tables it never touches, and should never hold admin rights. When an attacker does find a hole, least privilege is the difference between a leaked search result and a dumped customer database. Separate accounts per service, no shared god-user.
Validation is worth doing. Reject an email that is not an email, cap string lengths, constrain numeric ranges. But treat it as a secondary layer, not the primary control. Blocklists that strip quotes or the word SELECT are routinely bypassed with encoding, casing, and comment tricks, and they break legitimate input like the name O'Brien. Validation reduces noise and catches obvious junk. Parameterization is what actually closes the vulnerability. Do both, and never let validation lull you into concatenating a "clean-looking" string.
You want to know when someone is probing you. A few things pay off:
WAF: A web application firewall catches common injection payloads and buys time, though it is a filter in front of the real fix, not a replacement for it.
Error monitoring: Spikes in database syntax errors are a strong signal that someone is fuzzing your parameters. Alert on them. Also make sure raw DB errors never reach the client, since they hand attackers a map.
Query and audit logging: Log unusual query shapes and volume. A sudden UNION in your logs or a single endpoint pulling far more rows than usual deserves a look.
For the wider context on where this sits among application risks, see the OWASP Top 10 and our pillar on application security best practices.
Parameterize every query, no exceptions, and treat string-built SQL as a bug in code review the same way you would treat a hardcoded password. Lean on your ORM but audit every raw-query escape hatch. Allowlist any identifier that has to be dynamic. Run each service on a least-privilege database account, add validation and a WAF as outer layers, and alert on database error spikes. Injection is a solved problem at the code level. The only reason it persists is that one concatenated query slips through. Make that the thing your team refuses to ship.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A production-focused CircleCI guide: orbs, reusable commands and executors, workflows with fan-in/fan-out, contexts and OIDC for secrets, layered caching, test splitting, approval jobs, and dynamic config β with copy-paste examples.
A field-tested workflow for finding which process burns your CPU and whether the time is user, system, or iowait.
Explore more articles in this category
Run only the CI jobs a change actually affects using if conditionals, trigger path filters, and per-job path detection in a monorepo.
A prioritized toolkit for cutting CI time: measure the critical path first, then cache, parallelize, run only what changed, and shrink the work itself.
Stop stashing long-lived AWS access keys in GitHub secrets and let OIDC hand your workflows short-lived, scoped credentials instead.
Evergreen posts worth revisiting.