It's 2026 and Injection Still Happens. Here's Why.

SQL injection is old enough to rent a car, yet it keeps shipping — often via string interpolation that looks harmless. Why parameterization is non-negotiable, especially in generated code.

username' OR 1=1 --users2026stillparams, not strings
Rick
Senior Security Engineer
Feb 19, 2026
5 min read

Every year or so, someone asks me why I still bring up SQL injection in training sessions. "Isn't that solved? The ORM handles it." And every year, without fail, I find at least one injectable query in a real, modern, well-maintained codebase — usually written that quarter, usually by a good engineer, usually three lines from perfectly safe code. Injection isn't a solved problem. It's a survived problem, and the difference matters for how you review.

The reason it survives is simple: the vulnerable pattern is the intuitive one. String interpolation is how programmers think. "Build the query text, then run it" is the mental model everyone arrives with, and every language makes it one character away: a #{}, an f-string, a ${}. The safe pattern — send the query and the data down separate channels — has to be learned, and re-learned at every boundary where code talks to another interpreter.

The one-sentence mental model

Here's the framing that made injection click for me, and it covers SQL, shell, LDAP, all of it: injection happens whenever data gets promoted to code. You had a string that was supposed to be a value — a search term, a filename, a user ID — and because of how you assembled the final instruction, the interpreter on the other end read part of it as instructions. Everything else is dialect.

Which means the fix is always the same shape too: keep data and instructions in separate channels all the way to the interpreter, so nothing you concatenate can change what the instruction means.

SQL: the ORM won't save you at the edges

ORMs parameterize by default, and that's genuinely why injection is rarer than it was. But every ORM has escape hatches — and the escape hatches are exactly where teams go when queries get interesting:

# Vulnerable: params[:sort] is promoted from data to SQL
Order.where("status = '#{params[:status]}'")
     .order("#{params[:sort]} DESC")

The where clause is the classic. The order clause is the one people miss — you can't parameterize a column name, so it needs an allow-list instead:

SORTS = { "date" => :created_at, "total" => :total_cents }.freeze

Order.where(status: params[:status])
     .order(SORTS.fetch(params[:sort], :created_at) => :desc)

Two rules fall out of this. Values get bound parameters, always, no exceptions for "it's just an internal admin page." Identifiers — column names, table names, sort directions — can't be bound, so they get mapped through a fixed allow-list where the user's input selects from your strings and never becomes the string itself.

When I see raw SQL in a diff, I don't ask "is this input dangerous?" That question invites rationalization ("it comes from a dropdown, it's fine") and dropdown values are attacker-controlled the moment someone opens a terminal. I ask the structural question instead: is any runtime string interpolated into query text? If yes, the review comment writes itself, regardless of where the string comes from today.

Command injection: the quieter sibling

SQL injection gets the fame, but command injection is often worse when it lands, because the prize is your server rather than your database. And it hides in utility code nobody security-reviews: thumbnail generation, PDF export, "just shell out to ffmpeg."

# Vulnerable: filename is promoted from data to shell syntax
subprocess.run(f"convert {filename} -resize 200x200 thumb.png",
               shell=True)

A filename containing shell metacharacters stops being a filename. Same disease, same cure — separate the channels:

subprocess.run(
    ["convert", filename, "-resize", "200x200", "thumb.png"]
)

The argument-list form never invokes a shell, so there's no interpreter to confuse; the filename can only ever be an argument. In review, shell=True in Python, backticks or system("...") with interpolation in Ruby, exec with a concatenated string in Node — these are flag-on-sight patterns. There's almost always an argv-style API next door, and "almost always" means the rare genuine exception deserves a comment explaining itself.

Why generated code makes this urgent again

Here's the trend that pushed me to write this piece. Injection was declining for years because frameworks made the safe path the default path. AI assistants have partially reversed that, for a very mechanical reason: they were trained on decades of tutorials, Stack Overflow answers, and blog posts — and a huge fraction of that corpus interpolates strings into queries, because that's how examples were written for twenty years.

So when someone asks an assistant for "a search endpoint with dynamic filtering," there's a real chance the result builds SQL by concatenation, wrapped in clean structure, good naming, and confident comments. It looks more finished than hand-written code, which quietly lowers reviewer suspicion at exactly the wrong moment. The polish is a costume. Generated data-access code should get the same structural scan as an intern's first query — not because assistants are bad, but because they optimize for plausible, and injectable code has been plausible since 1998.

The good news: the structural review is fast. You're not auditing logic; you're pattern-matching for one thing — runtime strings crossing into interpreter input. Grep-level attention catches most of it: interpolation inside where(, f-strings feeding execute(, shell=True, template literals inside query builders.

Defense in depth, briefly

Parameterization is the fix, but two backstops are worth having for the day something slips through. Run the app's database user with least privilege — a web app that can DROP TABLE is carrying capability it never needs, and an injection into a read-only reporting path should hit a wall at the grant level. And keep query errors out of user-facing responses; verbose database errors are free reconnaissance, turning blind probing into guided probing.

Neither replaces parameterization. They just decide how bad the bad day gets.

What I scan for in every data-access diff

  • Any string interpolation or concatenation feeding a query, anywhere — including order, group, and raw fragments, not just where.
  • Identifiers (columns, sort fields, table names) coming from input: are they mapped through an allow-list, or passed through?
  • Shell-outs: argv-array form, or a single string handed to a shell?
  • Does the code's polish match its safety, or is it confident-looking generated code that skipped the parameterization it never learned?
  • If this query were injectable, what could the database user actually do? Least privilege as the seatbelt.

Injection has been on every top-ten vulnerability list since those lists existed, and it stays there because it rides on the most natural habit in programming. The reviewers who catch it aren't the ones who know the most exploit trivia. They're the ones who ask, on every diff, one boring question: does data ever get to become code here?

sql-injectioncommand-injectionparameterizationinput-validationcode-review
Written by
Rick
Senior Security Engineer · Firetrail review team
More Security