Prisma $executeRawUnsafe Security: When Raw Queries Reopen SQL Injection (2026)

Your app has a table view with sortable columns. Someone asked Cursor or Claude for "let users sort by any column", and it shipped something like this:

The pattern that shows up in almost every AI-generated table endpoint
// app/api/orders/route.ts
const { sortBy, dir } = await request.json();

const orders = await prisma.$queryRawUnsafe(
  `SELECT * FROM "Order" WHERE "userId" = '${userId}' ORDER BY "${sortBy}" ${dir}`,
);

That runs. It passes review, because Prisma has a reputation for being injection-proof and the code says prisma. right there at the front. It is also a SQL injection bug, and the Unsafe in the method name is the only warning you got.

TL;DR

Prisma's $queryRaw and $executeRaw are tagged templates that escape every variable, so they're genuinely safe. Their Unsafe twins take a plain string and send whatever you concatenated. Prisma's docs limit all four to one statement per call, so nobody appends DROP TABLE. The realistic damage is a rewritten WHERE clause on an UPDATE or DELETE that quietly matches every row.

Why the Unsafe methods exist at all

This is the part most write-ups skip, and it explains why the methods keep appearing in code nobody meant to write insecurely.

SQL placeholders bind values, not identifiers. You cannot parameterize a column name, a table name, or a keyword like ASC. Prisma's documentation is explicit about it:

"Template variables can only be used for data values (such as email in the example above). Variables cannot be used for identifiers such as column names, table names or database names, or for SQL keywords."

The docs then show the query that does not work:

What Prisma says will not work
const myTable = "user";
await prisma.$queryRaw`SELECT * FROM ${myTable};`;

So the moment a feature needs a dynamic column, the safe API stops being an option and the docs point you at $queryRawUnsafe. An AI agent asked for sortable columns, dynamic filters, or a generic search endpoint hits that wall in about four seconds and takes the exit. It isn't being reckless. It's the only method that compiles.

Ask an agent to "make the sort dynamic" and it will reach for the Unsafe method without flagging it, because that is the correct API for the request as stated. The fix is to change the request: "make the sort dynamic, validating sortBy against an allowlist of permitted column names."

The four methods, and which two escape

MethodInputReturnsEscapes variables
$queryRawTagged templateRowsYes
$executeRawTagged templateRow countYes
$queryRawUnsafePlain stringRowsNo
$executeRawUnsafePlain stringRow countNo

The query versions read. The execute versions write, which is why $executeRawUnsafe is the one worth finding first. A read bug leaks data. A write bug changes it.

Prisma's warning on the Unsafe methods is direct: "If you use this method with user inputs (in other words, SELECT * FROM table WHERE columnName = ${userInput}), then you open up the possibility for SQL injection attacks." The raw queries docs add that "wherever possible you should use the $executeRaw method instead of $executeRawUnsafe".

What an attack actually looks like here

Most SQL injection articles open with '; DROP TABLE users; --. Against Prisma that payload fails, and it's worth knowing why before you write off a finding as unexploitable.

Prisma's docs state that all four raw methods "can only run one query at a time". $executeRawUnsafe explicitly does not support multiple queries in a single string. Append a second statement and the driver rejects the whole thing.

That constraint is narrower than it sounds. The attacker doesn't need a second statement. They need to change the meaning of the one you already sent.

A single-statement attack on $executeRawUnsafe
// The endpoint: mark one of the current user's orders as cancelled
await prisma.$executeRawUnsafe(
  `UPDATE "Order" SET status = 'cancelled' WHERE id = ${orderId} AND "userId" = '${userId}'`,
);

// Attacker sends orderId = "1 OR 1=1 --"
// The database receives:
//   UPDATE "Order" SET status = 'cancelled' WHERE id = 1 OR 1=1 --  AND "userId" = '...'
// The comment kills the ownership check. Every order in the table is now cancelled.

One statement. No DROP. Your entire orders table is cancelled, and the endpoint returns a row count instead of an error, so nothing in your logs looks like an attack until a customer emails.

$executeRaw* returns the number of affected rows. That number is your best detector. An endpoint that should only ever touch one row and returns 4,812 is not a performance anomaly.

The same trick on the sort endpoint from the top of this page reads data rather than destroying it. ORDER BY "${sortBy}" with sortBy set to a subquery, or a sortBy that closes the quote and appends a UNION, turns a table view into an arbitrary-read primitive against every table the connection can reach. Prisma connections are usually the database owner, so that is every table.

The Prisma.raw trapdoor

You can also reintroduce the bug inside the safe methods. Prisma.sql builds a parameterized fragment. Prisma.raw does not: it inserts its argument verbatim, no escaping.

Safe method, unsafe result
// Looks parameterized. Is not.
await prisma.$queryRaw`SELECT * FROM "Order" ORDER BY ${Prisma.raw(sortBy)}`;

Prisma's docs name this directly: "Another way to make these methods vulnerable is misuse of the Prisma.raw function." A grep for RawUnsafe misses this entirely, which is why the audit below searches for both.

How to fix it

The fix is an allowlist. Not sanitization, not escaping, not a regex that strips quotes and semicolons. An allowlist of exact strings you decided to support.

1

Allowlist the identifier

const SORTABLE = ["createdAt", "total", "status"] as const;
const DIRECTIONS = { asc: "ASC", desc: "DESC" } as const;

const column = SORTABLE.includes(sortBy) ? sortBy : "createdAt";
const direction = DIRECTIONS[dir] ?? "DESC";

Note that direction is looked up in a map rather than validated. The value that reaches the query is one you wrote, so there's nothing left for an attacker to influence.

2

Parameterize everything that is a value

Identifiers need the allowlist. Values do not, and mixing the two approaches is where people slip. Once the column is safe, put the values back in a tagged template:

const orders = await prisma.$queryRaw`
  SELECT * FROM "Order"
  WHERE "userId" = ${userId}
  ORDER BY ${Prisma.raw(`"${column}"`)} ${Prisma.raw(direction)}
`;

userId is escaped by Prisma. column and direction are safe because they came out of your own constants, not the request.

3

Ask whether you needed raw SQL

A surprising share of $queryRawUnsafe calls in AI-generated code exist because the agent didn't know Prisma's own API covered the case. Dynamic sorting is supported by the query builder:

const orders = await prisma.order.findMany({
  where: { userId },
  orderBy: { [column]: direction.toLowerCase() },
});

The allowlist still matters, since column becomes an object key. But there's no SQL string to get wrong, and Prisma rejects an unknown field outright.

Auditing what your agent wrote

Two greps, both worth running. The second one is the one people forget.

Find every raw query in the codebase
# The obvious one
grep -rn 'RawUnsafe' --include='*.ts' --include='*.js' .

# The one that hides inside "safe" tagged templates
grep -rn 'Prisma\.raw' --include='*.ts' --include='*.js' .

For each hit, the question isn't "is this escaped". It's: can any part of this string be influenced by a request? Trace the variable back to its source. If it came from req.body, searchParams, a header, or a database column that a user controls, it needs the allowlist treatment.

AI Prompt

Audit prompt for an AI agent

Find every call to $queryRawUnsafe, $executeRawUnsafe, and Prisma.raw in this codebase. For each one, trace every interpolated variable back to its origin and tell me whether a user can influence it through a request body, query string, header, or stored value. Report the file, the line, and the traced path. Do not change any code yet.

The "do not change any code yet" matters. An agent left to fix these on its own tends to wrap the input in a regex that strips semicolons and call it done, which stops the payload in the example above and none of the others.

Is $executeRawUnsafe safe if I validate the input first?

Only if you validate against an allowlist of exact permitted strings, not against a pattern. Checking that a column name contains no quotes or semicolons is a blocklist, and blocklists on SQL syntax lose. Compare the input to a hardcoded array of column names you actually support and reject anything that isn't in it.

Can an attacker DROP TABLE through $executeRawUnsafe?

Not by appending a second statement. Prisma's docs state that all four raw methods can only run one query at a time, so the classic stacked payload fails. The realistic attack rewrites the single statement you already sent. On an UPDATE or DELETE that means changing which rows it matches, which can be all of them.

What is the difference between $executeRaw and $executeRawUnsafe?

$executeRaw is a tagged template. Prisma escapes every interpolated variable and sends a parameterized query. $executeRawUnsafe takes an ordinary JavaScript string, so whatever you concatenated into it goes to the database as SQL. The names are the whole API contract.

Why does my code use $queryRawUnsafe for sorting?

Because SQL placeholders bind values, not identifiers. Prisma's docs say template variables cannot be used for column names, table names, or SQL keywords. A dynamic ORDER BY column can't be parameterized, so any AI-generated sortable endpoint reaches for the Unsafe method. That's the single most common reason it shows up in a vibe-coded codebase.

Is Prisma.raw safe to use inside $queryRaw?

No. Prisma.raw inserts its argument verbatim with no escaping, which is exactly the behaviour the tagged template exists to prevent. Prisma's own docs call misuse of Prisma.raw a way to make the safe methods vulnerable. Use it only with strings you wrote yourself, never with anything derived from a request.

Not sure what your API is exposing?

CheckYourVibe scans your deployed app for injection-prone endpoints, exposed admin paths, and secrets in client bundles. Free, and it takes about two minutes.

Vulnerability Guides

Prisma $executeRawUnsafe Security: When Raw Queries Reopen SQL Injection (2026)