Drizzle ORM Had a Real SQL Injection, and the Fix Was Refreshingly Boring

TL;DR: I found a SQL injection in Drizzle ORM that sat in the identifier escaping path, which is exactly where a lot of people would assume things are safe. The bug was simple, the impact was not, and the disclosure process was one of the smoother ones I have dealt with.

Drizzle had a real SQL injection bug. (here)

Not a weird edge case. Not some contrived sql.raw() footgun where the answer is just "well yeah, you passed raw SQL." This one lived in identifier escaping, which is a much nastier place for a bug like this to sit, because it makes unsafe code look safe. The accepted advisory describes it pretty plainly: escapeName() wrapped identifiers in quotes, but never escaped the quote delimiter inside the identifier itself. That made sql.identifier(), .as(), and $with() injectable across the affected dialects.

The core bug was almost insulting in how small it was.

For PostgreSQL and SQLite, Drizzle did this:

Code:
escapeName(name: string): string {
return "${name}";
}

For MySQL and SingleStore, same idea, just with backticks:

Code:
escapeName(name: string): string {
return \${name}``;
}

That means if user input contains the quoting character, the identifier closes early and the rest becomes attacker controlled SQL. The fix was the boring one every SQL implementation should have had from day one: double the delimiter inside the identifier before wrapping it.

What made it interesting was how normal the vulnerable code looked.

A developer sees something like this and thinks they are doing the careful version:

Code:
const sortColumn = req.query.sort;

const result = await db
.select()
.from(users)
.orderBy(sql${sql.identifier(sortColumn)} ASC);

That looks fine at a glance. It even looks responsible. But if sortColumn is attacker controlled, the whole thing falls apart. One of the proof snippets I used was:

Code:
const userInput = 'id" ASC, CAST((SELECT password_hash FROM users LIMIT 1) AS int)--';

const query = sqlSELECT * FROM ${sql.identifier("users")} ORDER BY ${sql.identifier(userInput)} ASC;
const compiled = db.dialect.sqlToQuery(query);

// SELECT * FROM "users" ORDER BY "id" ASC, CAST((SELECT password_hash FROM users LIMIT 1) AS int)--" ASC

That is the bug in one screen. The payload closes the quoted identifier, injects arbitrary SQL, and comments out the trailing junk Drizzle appends after it.

And yes, this was exploitable in ways that actually matter.

The PoC was not just "look, the query string looks weird now." I built out the full attack chain and used it to steal an admin password hash through a PostgreSQL cast error, exfiltrate API keys and JWT secrets from another table, leak internal S3 backup URLs, dump the full users table, promote a regular user to admin, and destroy data with a stacked query path. I also reproduced the same class of issue in MySQL using the backtick breakout variant. The exploit script I kept for the writeup walks through all of that step by step.

What I liked here is that the bug was clear, and so was the fix.

This was not one of those miserable cases where you spend half the time arguing about whether the dangerous behavior is "intended." The suggested fix in the advisory was one line per dialect:

Code:
// PostgreSQL and SQLite
escapeName(name: string): string {
return "${name.replace(/"/g, '""')}";
}

// MySQL and SingleStore
escapeName(name: string): string {
return \${name.replace(/`/g, '')}\;
}

That is it. Standard SQL identifier escaping. No drama. No cleverness. Just the thing it should have been doing already.

The disclosure side was also a good experience, which is not always how these stories go.

I first created a GitHub advisory because that gave me a clean place to write up the issue properly. I also tried reaching out through [email protected]
, but that address was not available at the time, and Gmail basically told me it was not registered. So I joined the Drizzle Discord, opened a help channel, and from there the team replied both in-channel and in DMs. That part could have been awkward. It was not.

What happened next was exactly what you want from a project handling a real vulnerability. The advisory was disclosed on March 24, 2026. A few days later, Drizzle shipped version 0.45.2 on March 27, 2026 with a release note explicitly stating that sql.identifier() and sql.as() escaping issues had been fixed, and it credited the reporters.

That kind of response matters more than people think.

A lot of maintainers are good at shipping features and weirdly bad at receiving bad news. Drizzle was the opposite here. Fast replies, no ego, no pointless defensiveness, no weeks of pretending the bug is just "unsafe usage." They reviewed it, fixed it, pushed the release, accepted the advisory, and communicated like adults. That is how this is supposed to work.

I am not going to pretend the bug itself was small in impact. It was not. The accepted advisory classed it as a critical SQL injection with CWE-89 implications across PostgreSQL, MySQL, SQLite, and SingleStore, especially in apps doing dynamic sorting, dynamic reporting, or anything else that let user input flow into identifiers. But the handling was solid, and I think that is worth saying clearly.

One thing I think is worth calling out plainly:

The dangerous part here was not that Drizzle lets people write SQL. Every ORM has sharp edges somewhere. The dangerous part was that this lived in an API that reads like it should protect you. sql.identifier() sounds safe. It sounds like the ORM is taking responsibility for escaping the identifier correctly. If the implementation then just slaps quotes around attacker input and calls it a day, that is not a footgun anymore. That is a trap.

So, what is the actual takeaway?

Validate anything dynamic that lands in identifier position. Better yet, do not let user input pick arbitrary identifiers in the first place. Use allowlists for sort columns, aliases, and dynamic report fields. And if your library exposes helper functions that imply safety, those helpers need to actually do the escaping correctly. Anything less is just dressed-up raw SQL.

As for Drizzle itself, this one is fixed.

Release 0.45.2 contains the escaping fix, and the project release notes state that the issue affected sql.identifier() and sql.as(). The repository was sitting at about 33.5k GitHub stars when the fix shipped, so this was not some tiny abandoned package nobody uses. Plenty of people build real systems on top of it. That makes getting the boring details right even more important.

My honest opinion?

The bug was real, the exploit path was clean, and the fix was embarrassingly simple. That usually means one thing: this should have been caught earlier. Still, when I reported it, the Drizzle team handled it well, moved fast, and did the part that matters. I will take a team that fixes a bad bug professionally over a team that tries to win an argument about it every single time.
 
Back
Top