SQL injection has carried the MITRE identifier CWE-89 since the CWE list's earliest revisions, and it has sat inside the OWASP Top 10 in every edition since the project started publishing one in 2003. That longevity is not because the fix is unknown — parameterized queries have been the standard defense for over two decades — but because JavaScript's template literals make the unsafe pattern read as idiomatic code. Writing `SELECT * FROM users WHERE id = ${id}` feels natural in a language built around string interpolation, and it compiles, runs, and passes tests right up until someone submits id=1 OR 1=1--. Node's most-used database drivers, mysql2 and pg (node-postgres), both ship first-class parameterization — ? placeholders in mysql2, numbered $1, $2 placeholders in pg — but neither driver stops a developer from bypassing that mechanism entirely by building the query string themselves. This piece walks through exactly where CWE-89 shows up in real Node code: raw driver queries, Sequelize's sequelize.query() escape hatch, and the identifier-interpolation cases that parameterization can't cover at all, plus how source-to-sink static analysis catches the pattern before it ships.
What does CWE-89 actually look like in a Node.js codebase?
CWE-89 in Node almost always looks like user input concatenated or interpolated directly into a SQL string that's then executed. With mysql2, the vulnerable form is connection.query(`SELECT * FROM orders WHERE user_id = ${req.params.id}`) — the driver has no way to distinguish that string from a hardcoded one, because by the time it receives it, the query and the data are already fused. The safe equivalent is connection.query('SELECT * FROM orders WHERE user_id = ?', [req.params.id]), where the placeholder and the value travel to the server as separate protocol messages, so the database never parses req.params.id as SQL syntax regardless of its contents. pg follows the same shape with $1-style placeholders and a separate values array argument to client.query(text, values). The bug is rarely a misunderstanding of SQL — it's a habit carried over from every other place in a Node codebase where template literals are the correct tool, applied to the one place they aren't.
Why do template literals keep causing this in mysql2 and pg specifically?
Template literals cause this because they make the unsafe and safe code paths visually almost identical, and neither mysql2 nor pg intervenes to stop it — parameterization in both libraries is opt-in, not the only code path available. A developer refactoring connection.query('SELECT * FROM users WHERE id = ?', [id]) into a multi-condition dynamic query often reaches for template-literal string building because it's easier to compose conditionally (`WHERE id = ${id} ${extraClause}`), and once one interpolated value sneaks in, the whole query is a text field, not a parameterized statement. Neither driver's query() method rejects a plain interpolated string — it's syntactically indistinguishable from a static query, so there's no runtime signal that anything went wrong until an attacker supplies a value that breaks out of the intended clause. This is also why simple pattern-matching linters struggle here: connection.query(someVariable) looks identical whether someVariable was built safely or not, which is exactly the class of bug that requires tracing where someVariable's contents actually came from.
Does using an ORM like Sequelize make this go away?
Using an ORM removes most, but not all, of the risk — Sequelize parameterizes automatically when you use its query-builder API (Model.findAll({ where: { id } }), Model.create(), and similar methods all bind values as parameters under the hood), but it ships a deliberate raw-SQL escape hatch, sequelize.query(rawSqlString), that is not safe by default. Sequelize's own documentation is explicit that raw queries built with string concatenation or interpolation are vulnerable, and that safety requires passing values through the replacements (named or positional, :name or ?) or bind ($1) options rather than building the SQL string with untrusted input directly. Because sequelize.query() is commonly reached for when the query-builder API can't express something — a complex join, a vendor-specific function, a performance-tuned query — it tends to appear in the parts of a codebase that get the least routine review, which is exactly where an injected value is most likely to survive to production. The lesson generalizes past Sequelize: any ORM's raw-query or raw-expression escape hatch (Prisma's $queryRawUnsafe, TypeORM's query(), Knex's knex.raw()) inherits the same risk as a bare driver call, regardless of how safe the rest of the ORM's API is.
Are some ORMs safer by default than others here?
Some are meaningfully safer by construction. Prisma's tagged-template $queryRaw function — prisma.$queryRaw`SELECT * FROM users WHERE id = ${id}` — parameterizes the interpolated values automatically even though it looks like ordinary string interpolation, because it's a tagged template literal, not a plain string: Prisma's runtime receives the literal segments and the values separately and builds a parameterized statement, so the syntax that's dangerous in a raw pg or mysql2 call is safe here by design. Prisma pairs this with $queryRawUnsafe, an explicitly and separately named method that takes a plain string and does not parameterize — the naming itself is a deliberate signal that this is the dangerous path. This is a real, verifiable design difference worth knowing when choosing a data-access layer: Prisma makes the safe pattern and the dangerous pattern look different in source code, while raw mysql2/pg template-literal queries and Sequelize's sequelize.query() make them look nearly identical.
What can't be parameterized, and how should that be handled?
Column names, table names, and sort directions (ORDER BY ${column} ${direction}) cannot be parameterized at all — the SQL protocol's placeholder mechanism binds values, not identifiers or keywords, so ? or $1 in an identifier position is a syntax error, not a safety improvement. The OWASP SQL Injection Prevention Cheat Sheet documents this gap directly and recommends allow-listing: validate the requested column or direction against a fixed, hardcoded set of permitted values (const ALLOWED = ['name', 'created_at', 'price']) before it ever reaches string construction, rather than attempting to sanitize or escape it. This is a common blind spot because it looks superficially like the same problem parameterized queries solve, and teams sometimes reach for manual escaping functions instead — which is the same mistake underlying most historical SQL injection vulnerabilities across every language, not just Node.
How does Safeguard catch this before it ships?
Safeguard's SAST engine runs source-to-sink dataflow analysis on JavaScript and TypeScript specifically to catch this pattern: it traces untrusted input — a request parameter, a query string value, a CLI argument — across functions and files to see whether it reaches a dangerous sink, with a SQL query explicitly named as one of the sink categories it tracks. A finding carries the full dataflow trace, the CWE-89 mapping, severity, and the exact code location, so a developer sees the concrete path from req.params.id to the vulnerable connection.query() call rather than a bare "possible SQL injection" warning with no context. Because the analysis follows real data flow instead of matching on driver method names, it distinguishes a parameterized mysql2 call from an interpolated one automatically, and flags the risky sequelize.query() or $queryRawUnsafe call sites where a raw-SQL escape hatch actually receives attacker-influenced input — the exact gap that pattern-matching linters and generic ORM usage reviews tend to miss.