A single unescaped template string is all it takes. In 2023, a security advisory landed against Sequelize — one of the most widely used ORMs in the Node.js ecosystem — describing a SQL injection vector that appeared when developers mixed bind parameters with raw replacements in the same query. It is exactly the kind of mistake that looks harmless in a code review and catastrophic in a database log. SQL injection has sat inside the OWASP Top 10 since the list's inception, and in the 2021 edition, the Injection category (which SQLi anchors) still showed up in roughly 94% of applications tested for it, spanning over 274,000 recorded occurrences. Node.js is not exempt from this history. Query builders like Knex, ORMs like Sequelize and Prisma, and raw drivers like pg and mysql2 all expose sharp edges that a rushed WHERE ${userInput} can turn into a full database compromise. This post walks through how SQL injection actually happens in JavaScript backends, what a genuinely safe query looks like, and how to catch the gap before it ships.
Why does SQL injection still show up in Node.js apps?
It persists because JavaScript makes string concatenation the path of least resistance, and most Node.js database libraries will happily execute whatever string you hand them. Template literals were designed to make string building ergonomic — that same ergonomics is what makes `SELECT * FROM users WHERE email = '${email}'` feel like the natural way to write a query at 11pm before a deploy. Unlike languages where the standard library nudges you toward prepared statements, the low-level Node.js drivers (mysql, mysql2, pg, sqlite3) all accept raw SQL strings by design, because that flexibility is also what powers migrations, admin tooling, and reporting queries. The result is a language and ecosystem where the unsafe pattern and the safe pattern look almost identical on the page, differing by a template literal versus a placeholder. Add in the churn of npm dependencies — a typical mid-sized Node.js service pulls in several hundred transitive packages, several of which may touch query construction indirectly through logging, caching, or ORM plugins — and the attack surface for injection multiplies well beyond the code a team writes by hand.
What does a real SQL injection vulnerability look like in Node.js code?
It looks like user input concatenated directly into a query string, and it shows up in nearly every popular data-access layer, not just raw drivers. With the pg package:
// Vulnerable
const result = await pool.query(
`SELECT * FROM invoices WHERE customer_id = '${req.query.id}'`
);
A request like ?id=1' OR '1'='1 returns every invoice in the table; ?id=1'; DROP TABLE invoices;-- (on drivers or configurations that allow stacked queries) can destroy it. The same failure mode reappears inside ORMs that are otherwise considered "safe by default." Sequelize's sequelize.literal() and the Sequelize.query() raw-query escape hatch both accept unsanitized strings:
// Vulnerable, despite using an ORM
const users = await sequelize.query(
`SELECT * FROM users WHERE username = '${username}'`
);
Knex.js has the identical problem with knex.raw() and whereRaw() when interpolation is used instead of bindings. And the 2023 Sequelize advisory mentioned above showed that even the "correct-looking" API could be misused: mixing positional replacements with named bind parameters in a single raw query allowed an attacker-controlled replacement value to escape its intended boundary. The lesson across all of these is the same — the vulnerability isn't the library, it's any code path where a value that originated from a request, header, cookie, or upstream API call reaches the SQL engine without being passed as a bound parameter.
What's the actually safe way to query a database from Node.js?
The safe way is parameterized queries, full stop — every mainstream Node.js driver supports them, and using anything else for user-controlled input is the actual vulnerability. With pg, that means positional placeholders:
// Safe
const result = await pool.query(
'SELECT * FROM invoices WHERE customer_id = $1',
[req.query.id]
);
With mysql2, it's ? placeholders and a values array:
// Safe
const [rows] = await connection.execute(
'SELECT * FROM invoices WHERE customer_id = ?',
[req.query.id]
);
In both cases, the driver sends the query text and the parameters to the database separately — the database compiles the query plan first and only then substitutes the values as literal data, never as executable SQL. This is why parameterization closes the vulnerability class entirely rather than just making exploitation harder: there is no string for an attacker to break out of. For ORMs, the equivalent discipline is using the query-builder methods (.where(), .findOne({ where: { id } }), Prisma's typed findMany) instead of raw query helpers, and if a raw query is genuinely required, using the library's own parameter-binding syntax rather than template-literal interpolation.
Can using an ORM like Sequelize or Prisma guarantee you're safe?
No — an ORM reduces risk but does not eliminate it, because every major ORM ships an escape hatch for raw SQL that bypasses its own protections. Prisma's $queryRawUnsafe() and Sequelize's literal() exist for legitimate reasons: complex joins, vendor-specific functions, and performance-sensitive reporting queries that the query builder can't express cleanly. But "unsafe" is in the function name for a reason, and it's common to find these escape hatches used for ordinary CRUD logic simply because a developer wanted string interpolation to build a dynamic ORDER BY clause or an optional filter. Prisma's safer counterpart, $queryRaw using tagged templates, does parameterize automatically — but only if it's used instead of $queryRawUnsafe, and only if every interpolated value in the tagged template is itself a bound value rather than a table or column name (identifiers can't be parameterized in any driver, which is its own common source of injection when dynamic sorting or filtering is built from user input). The practical takeaway: an ORM shifts the default behavior toward safety, but a single grep -r "queryRawUnsafe\|literal(\|\.raw(" across a codebase is often enough to surface the handful of call sites where that default was overridden.
What other defenses matter beyond parameterized queries?
Parameterized queries close the injection itself, but layered controls limit the blast radius when something is missed. Database accounts used by application services should run under least-privilege grants — a reporting endpoint's database user has no legitimate reason to hold DROP TABLE or GRANT privileges, yet it's common to find application connection strings using an account with full schema ownership. Drivers that support it (notably the legacy mysql package) should have multipleStatements disabled unless a specific migration tool requires it, since stacked-query injection depends on that flag being enabled. Input validation at the API boundary — rejecting a customer_id that isn't numeric before it ever reaches a query — adds a second independent check rather than relying solely on the data layer. Static analysis rules such as eslint-plugin-security's detect-non-literal-fs-filename-style checks and custom lint rules that flag template literals passed into .query() or .raw() calls catch the pattern at commit time. And centralized, generic error handling matters more than it gets credit for: a stack trace or raw database error returned in an API response frequently hands an attacker the exact table and column names they need to refine an injection payload.
How do teams catch SQL injection before it ships?
They catch it by treating query construction as a reviewable, testable, and continuously scanned surface rather than a one-time code review item. That means static analysis (SAST) rules that specifically flag string concatenation or template literals feeding into query execution functions across pg, mysql2, sequelize, knex, and prisma call sites — generic linting often misses these because the vulnerable pattern is syntactically valid, idiomatic JavaScript. It means dependency scanning that tracks advisories against the ORMs and query builders already in a project's package.json, since the 2023 Sequelize issue and similar advisories in other packages are only useful if a team actually gets alerted and upgrades. It means dynamic testing (DAST) or targeted fuzzing of API parameters that reach a database layer, because static review can't always trace input all the way from an Express route handler through three layers of service functions into a raw query. And it means making all of this a CI gate rather than a periodic audit — a SQL injection pattern introduced in a Tuesday afternoon pull request should be flagged before merge, not discovered six months later during a pen test.
How Safeguard Helps
Safeguard is built to catch exactly this class of issue at the point it enters a codebase, not after it reaches production. Our SAST engine ships with rules tuned to the Node.js ecosystem specifically — flagging string-concatenated or template-literal SQL passed into pg.query(), mysql2.execute(), sequelize.query()/literal(), knex.raw()/whereRaw(), and prisma.$queryRawUnsafe() — so the exact patterns walked through above are caught as pull-request annotations, with the vulnerable line and the parameterized fix suggested inline. Because SQL injection risk in JavaScript is inseparable from the ORM and driver versions a project depends on, Safeguard's software composition analysis continuously cross-references your package.json and lockfiles against known advisories, including ORM-specific ones like the Sequelize bind/replacement issue, and surfaces which of your services are actually affected rather than issuing a blanket CVE alert. These checks run as CI gates, so a merge can be blocked automatically when a new raw-query call site with unvalidated input is introduced, turning a policy ("always parameterize") into an enforced control rather than a wiki page. For teams that need to demonstrate this control to auditors or customers, Safeguard maps these findings directly to SOC 2 and secure-SDLC evidence requirements, so the same scan that stops the injection also produces the audit trail proving it was caught. The goal isn't just detecting SQL injection — it's making the safe path the only path that reaches production.