Here's a fun fact that should keep you awake tonight: SQL injection has been the number-one web vulnerability on the OWASP list for over a decade. We've had smartphones, streaming services, and three different JavaScript framework renaissance eras — and we still can't stop concatenating user input into database queries.
This is not a story about sophisticated nation-state hackers. This is a story about a ' character and a developer who was in a hurry.
The Crime Scene
Let's say you're building a login endpoint. You're tired, the deadline is tomorrow, and honestly how bad could it be?
# Please never do this. I am begging you.
def get_user(username):
query = "SELECT * FROM users WHERE username = '" + username + "'"
return db.execute(query)
You call this with username = "alice" and everything works fine. You ship it. You go home. You sleep well.
Meanwhile, an attacker calls it with:
username = "' OR '1'='1
Your query becomes:
SELECT * FROM users WHERE username = '' OR '1'='1'
'1'='1' is always true. You've just handed over every row in your users table. Congratulations — you've invented an accidental all-access pass.
But wait, it gets worse. A slightly more motivated attacker tries:
username = "'; DROP TABLE users; --"
That -- is a SQL comment. Everything after it is ignored. Your query becomes:
SELECT * FROM users WHERE username = ''; DROP TABLE users; --'
Depending on your database driver, your entire users table just evaporated. Your backups better be current. Your résumé better be updated.
Why This Is Still Happening in 2026
The maddening thing about SQL injection is that the fix has existed since the late 1990s. Parameterized queries (also called prepared statements) completely neutralize the attack by treating user input as data, not as code. The database never interprets the input as SQL. Full stop.
And yet, every year, companies with engineering teams of hundreds get breached this way. Here's why:
- Copy-paste culture — Someone copies a quick query from Stack Overflow without checking if it's safe.
- "It's internal only" — Famous last words. Attackers get inside networks. Internal tools get exposed.
- ORMs create false confidence — ORMs protect you until you write a raw query, which everyone eventually does.
- No security review process — The feature shipped, the tests passed, nobody audited for injection.
The Fix: Parameterized Queries
Stop building SQL strings. Use parameterized queries everywhere, always, without exception.
# The right way — user input is passed as a parameter, not interpolated
def get_user(username):
query = "SELECT * FROM users WHERE username = %s"
return db.execute(query, (username,))
Now when an attacker passes ' OR '1'='1, it's treated as a literal string to match against the username column. No row has the username ' OR '1'='1, so you get zero results. The database never sees it as SQL. Attack neutralized.
The same principle applies regardless of your language or framework:
// Node.js with pg (PostgreSQL)
const result = await client.query(
'SELECT * FROM users WHERE username = $1',
[username] // passed separately — never interpolated
);
// Node.js with mysql2
const [rows] = await connection.execute(
'SELECT * FROM users WHERE username = ?',
[username]
);
Every major database library supports this. There is no legitimate reason to concatenate user input into a query string. If you find yourself doing it, stop, take a breath, and use a parameter.
ORMs: Great Until They're Not
ORMs like SQLAlchemy, Sequelize, Prisma, and Eloquent use parameterized queries under the hood by default. That's good. But the moment you reach for a raw query escape hatch, the training wheels come off.
The danger pattern is the "dynamic column" problem. You want to let users sort by any column, so you do:
# Dangerous! Column names can't be parameterized
sort_column = request.args.get("sort", "created_at")
query = f"SELECT * FROM posts ORDER BY {sort_column}"
Parameters only work for values, not identifiers (table names, column names, operators). For identifiers, you need an allowlist:
ALLOWED_SORT_COLUMNS = {"created_at", "title", "author"}
sort_column = request.args.get("sort", "created_at")
if sort_column not in ALLOWED_SORT_COLUMNS:
sort_column = "created_at" # safe default
query = f"SELECT * FROM posts ORDER BY {sort_column}" # now safe
Allowlists, not denylists. You define what's valid. Anything else gets rejected.
Defense in Depth
Parameterized queries are your primary defense, but a layered approach makes breaches survivable:
- Least privilege — Your app's database user should only have the permissions it actually needs. A read-heavy service shouldn't have
DROP TABLEprivileges. If an attacker exploits injection, they're limited to what your DB user can do. - Web Application Firewall (WAF) — Can block known SQL injection patterns. Not a substitute for parameterized queries, but a useful extra layer.
- Error handling — Never expose raw database error messages to users. They're a treasure map. Log them server-side, show users a generic message.
- Automated scanning — Run tools like
sqlmapagainst your own endpoints in a staging environment. Find your holes before attackers do.
The 2-Minute Audit
Right now, before you close this tab, open your codebase and search for these patterns:
# Look for query string building
grep -rn "query.*+.*request\|query.*f\"\|query.*format" --include="*.py"
grep -rn "query.*+.*req\.\|query.*\`" --include="*.js"
Any hit where user input touches a query string without going through a parameter is a potential injection point. Fix each one. It takes five minutes per instance. It saves your job.
SQL injection is the security equivalent of leaving your front door unlocked because your neighborhood seems safe. The lock exists. The fix is trivial. The consequences of skipping it are catastrophic.
Parameterize your queries. Add that allowlist. Run a scanner. Then come find me on X / Twitter and tell me you did it — or share this with the developer on your team who you know is still concatenating query strings.
The database has secrets. Let's keep them that way.