no-raw-identifier-interpolation
Disallow interpolating table, column or sort identifiers into a Drizzle `sql` tagged template, where bind parameters cannot reach them.
CWE: CWE-89 OWASP: A03:2021 – Injection
Detects an identifier — a table, a column, a sort direction — interpolated into
a sql`…` template. This rule is part of eslint-plugin-drizzle-security.
💼 This rule is set to error in the strict config.
Quick Summary
| Aspect | Details |
|---|---|
| CWE Reference | CWE-89 (SQL Injection) |
| Severity | Critical (CVSS 9.8) |
| Auto-Fix | ❌ No auto-fix available |
| Category | Security |
Why this matters
sql`…` parameterizes. That is the entire reason to use it, and it is why
these two lines get the same review:
await db.execute(sql`SELECT * FROM users WHERE id = ${id}`); // safe
await db.execute(sql`SELECT * FROM ${table}`); // injectableThe first is safe. The second is not, and no amount of care with the template
changes that, because a bind parameter can only ever be a value. $1 is a
placeholder in the value slot of the parse tree. No database accepts one where
a table, a column, or a sort direction belongs — so when you put an identifier
hole there, the driver has nothing to bind and splices your string in verbatim.
This is the shape behind GHSA-gpj5-g38j-94v9. It is invisible to every SQL-injection linter that decides by asking "is this a raw API", because this is the safe API.
The reason it survives review is that the remediation everyone knows —
"use a parameter" — is what the developer already believes they are doing.
Repeating it produces the loop the vulnerability came from. There are only two
real fixes, and this rule's messages name them: Drizzle's sql.identifier(),
or an allowlist.
❌ Incorrect
import { sql } from 'drizzle-orm';
// ❌ the advisory shape — table name from input
await db.execute(sql`SELECT * FROM ${table}`);
// ❌ column name in ORDER BY
await db.execute(sql`SELECT * FROM users ORDER BY ${req.query.sort}`);
// ❌ sort direction — two legal values, and neither is bindable
await db.execute(sql`SELECT * FROM users ORDER BY name ${dir}`);
// ❌ pre-quoting escapes nothing; it only makes the line look deliberate
await db.execute(sql`SELECT * FROM "${table}"`);✅ Correct
import { sql } from 'drizzle-orm';
// ✅ values — exactly what the template is for
await db.execute(sql`SELECT * FROM users WHERE id = ${id} LIMIT ${n}`);
// ✅ the escaper quotes the identifier properly
await db.execute(sql`SELECT * FROM ${sql.identifier(table)}`);
// ✅ an allowlist turns input into a value you wrote
const column = { name: 'name', created: 'created_at' }[input] ?? 'id';
await db.execute(sql`SELECT * FROM users ORDER BY ${sql.identifier(column)}`);
// ✅ a direction resolved to a literal
const dir = input === 'desc' ? 'desc' : 'asc';What this rule deliberately does not report
- Every value position.
WHERE id = ${id},LIMIT ${n},VALUES (${name}),SET a = ${v}are what the tagged template parameterizes correctly. A rule that fired on the API's intended use is a rule that gets switched off. - Drizzle's composition surface —
sql.identifier(),sql.join(),sql.fromList(),sql.placeholder(). These produce SQL chunks rather than spliced text, andsql.identifier()in particular is the fix this rule recommends; reporting the remediation punishes the correction.sql.raw()is pointedly not in that list — it is the one member of the family that does splice, sosql`SELECT * FROM ${sql.raw(table)}`is still a finding here. - A literal.
sql`SELECT * FROM ${'users'}`is a constant you typed. There is no untrusted input in it. - A nested
sql`…`fragment. Composition is Drizzle's intended primitive, and the nested template is checked on its own visit — so its holes are still covered, just not twice. - A file that never imports
drizzle-orm.sqlis far too common a local name to key on alone; the driver import is the gate that keeps this rule inside its own plugin.
How this splits with no-unsafe-query
The two rules divide by what is wrong, not by which API you used:
| reports | |
|---|---|
no-unsafe-query | string construction — concatenation or interpolation used to build the SQL text passed to sql.raw(...) |
| this rule | position — a hole where an identifier belongs, inside the tagged template |
So sql`SELECT * FROM ${sql.raw(table)}` is a finding here: the hole is
in an identifier position and sql.raw() splices rather than composing.
sql.raw('SELECT * FROM ' + table) is a finding there: the string is built
by concatenation. A line that does both is two different defects with two
different fixes, not one finding reported twice.
When Not To Use It
There is no configuration where interpolating an identifier into a query is correct, so this rule has no options — which SQL positions accept a bind parameter is fixed by the database's grammar, not by project preference.
If a specific line is genuinely a constant the analyzer cannot see through, disable it on that line with a reason rather than switching the rule off:
// eslint-disable-next-line drizzle-security/no-raw-identifier-interpolation -- TABLE is a module constant
await db.execute(sql`SELECT * FROM ${TABLE}`);Further Reading
- CWE-89: SQL Injection
- OWASP A03:2021 – Injection
- GHSA-gpj5-g38j-94v9 — the advisory this rule is anchored on
- Drizzle: magic
sqloperator
Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.