← All news
security5 min read

Why string concatenation in SQL queries still causes injection bugs

By Seaworthy · 21 July 2026

Lines of code on a computer screen

Photo by Chris Ried on Unsplash

SQL injection is the kind of bug that should feel like a relic. It's been in the OWASP Top 10 since OWASP had a Top 10. Every backend framework ships a parameterized query API. And yet it's still turning up in production code in 2026, usually not because anyone forgot it exists, but because an AI assistant wrote the vulnerable version first and it looked fine.

Here's the thing about string-built SQL: it works. It works in the demo, it works in the happy path, it works when you paste it into Postman with a normal email address. The bug is invisible until someone deliberately breaks it, which is exactly why it survives code review by people who are skimming for "does this return the right rows."

The pattern that keeps showing up

Ask an LLM for "a function to look up a user by email in Express with better-sqlite3" and you'll often get something close to this:

// routes/users.js
app.get('/users', (req, res) => {
  const email = req.query.email;
  const query = `SELECT id, name, email FROM users WHERE email = '${email}'`;
  const user = db.prepare(query).get();
  res.json(user);
});

It runs. It returns the user. Nobody's local curl request breaks it. Then someone requests /users?email=' OR '1'='1 and gets back the first row in the table, or worse, someone requests /users?email=x' UNION SELECT username, password, 1 FROM admins-- and now they have your admin credentials. The better-sqlite3 library, pg, mysql2, every major Node driver, supports parameter binding out of the box. The vulnerable version isn't the path of least resistance because the tooling requires it, it's the path of least resistance because it's the shortest string to type and the first thing that comes to mind when you're thinking in terms of "build a SQL string."

The fix is not more code. It's different code:

// routes/users.js
app.get('/users', (req, res) => {
  const email = req.query.email;
  const user = db.prepare('SELECT id, name, email FROM users WHERE email = ?').get(email);
  res.json(user);
});

One placeholder, one bound parameter. The driver quotes and escapes the value for you, and more importantly it never lets user input become part of the SQL grammar in the first place. That's the actual distinction that matters: a parameterized query separates code from data at the protocol level. A concatenated string asks the database to trust that your escaping was correct, for every input, forever.

Why ORMs don't save you here

"We use an ORM" is the most common reason teams assume they're not exposed. Prisma, Drizzle, TypeORM, Sequelize, they all parameterize the queries built through their query builder APIs. But every one of them also ships an escape hatch for raw SQL, because sometimes you need a window function or a recursive CTE the builder can't express. That escape hatch is where the same bug reappears with an ORM's name attached to it:

// Prisma, still vulnerable
const users = await prisma.$queryRawUnsafe(
  `SELECT * FROM users WHERE email = '${email}'`
);

$queryRawUnsafe does exactly what it says. Prisma also ships $queryRaw with tagged template literals, which parameterizes automatically, but the two look similar enough in an autocomplete dropdown that it's easy to grab the wrong one under deadline pressure, or have an AI assistant grab it for you.

Why this is an AI-coding-assistant problem specifically

Every LLM trained on public code has seen millions of tutorials, Stack Overflow answers, and blog posts that concatenate SQL, because concatenation is the version that's easiest to explain in three lines. Training data skews toward what's pedagogically simple, not what's production safe. Ask for "a quick script to query the database" and you'll usually get the quick version, because that's what "quick" has meant in the corpus. The model isn't being careless, it's reproducing the statistically dominant pattern for that prompt shape, and the dominant pattern predates widespread security review of tutorial code.

This is worse now than it was five years ago, not better, because more code is being generated per developer per day, and less of it is getting a second set of human eyes before it ships. A senior engineer writing this bug by hand in 2015 would probably catch it on re-read. A junior developer accepting an AI suggestion in 2026, who trusts that the assistant "knows" security best practices, often doesn't second-guess a query that returns the right answer on the first try.

What to actually check for

If you're auditing your own code or reviewing a PR, grep for these patterns and treat every hit as guilty until proven innocent:

  • Template literals or + concatenation feeding directly into a query, execute, raw, or exec call
  • $queryRawUnsafe, sequelize.query() with string interpolation, raw cursor.execute() calls in Python with % or .format() or f-strings
  • Any place user input (query params, form fields, headers, even values you consider "internal") reaches a database call without going through a placeholder

The fix is almost always mechanical: swap the interpolated string for a placeholder and pass the value as a bound parameter. It costs you nothing in readability and nothing in performance. There's no tradeoff here worth making.

Seaworthy's static analysis flags raw string concatenation feeding into SQL query calls as part of its SQL injection surface check, so this exact pattern gets caught before it reaches a PR review that's skimming for functionality rather than injection risk.

This article was generated by AI and summarises publicly available sources.

From the makers of Seaworthy

Seaworthy scans your repo for the issues covered in articles like this one.

Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.

$ npx seaworthycode