HomeGuidesSQL injection in Prisma, Drizzle, and Supabase
Guide
SQL injection in Prisma, Drizzle, and Supabase
Fix SQL injection in Prisma, Drizzle, and Supabase: tagged templates, never sql.raw with user input, and parameterize Postgres functions called via rpc.
Independent notes from VibeLocked. Not a pentest, and not affiliated with the tools named below.
SQL injection happens when untrusted input becomes part of the SQL text instead of a bound value. The attacker changes what the database runs: read other rows, drop data, or escalate through a powerful function. OWASP's SQL Injection Prevention Cheat Sheet puts the primary defense in one line: use prepared statements with parameterized queries, and stop building dynamic SQL with string concatenation.
In vibe-coded Next.js apps the ORM usually looks safe in the demo. The bug shows up when Cursor, Lovable, Bolt, or v0 reaches for a raw escape hatch: Prisma $queryRawUnsafe, Drizzle sql.raw, a hand-built string passed into supabase.rpc(), or a Postgres function that concatenates arguments into EXECUTE. This guide is the fix for those sinks. For row ownership bugs that are not injection, see fix IDOR in Next.js and Supabase and the common vulnerabilities survey.
Checked against the Prisma, Drizzle, Supabase, PostgreSQL, and OWASP docs linked below on October 16, 2026. Product names are here to help you find your stack. VibeLocked is independent and not affiliated with any of them.
The fix in five rules
- Prefer the query builder. Prisma
findMany/where, Drizzleselect().from().where(), Supabase.from().select().eq()all send values as parameters. - When you need raw SQL, use a tagged template that binds values. Prisma
$queryRaw`...`, Drizzlesql`...`. Never glue user input with+or${}into a plain string you then pass to an Unsafe orrawAPI. - Do not put identifiers in parameters. Table names, column names, and
ASC/DESCcome from an allow-list in your code, never from the request. - For Supabase
rpc, pass typed named args and keep the function body free of dynamic SQL. If the function must build SQL, useEXECUTE ... USINGfor values and an allow-list (orformat('%I', ...)) for identifiers. - Least privilege still matters. A secret or
service_rolekey, or aSECURITY DEFINERfunction owned bypostgres, turns one injected statement into a full database dump. Keep those off the client and review who canEXECUTEthe function.
How the bug looks in a generated Route Handler
AI tools often invent a "flexible search" that builds SQL from the query string:
// BAD: user input becomes SQL text
export async function GET(request: Request) {
const q = new URL(request.url).searchParams.get('q') ?? '';
const rows = await prisma.$queryRawUnsafe(
`SELECT id, email FROM "User" WHERE email LIKE '%${q}%'`,
);
return Response.json(rows);
}
A search of %' OR '1'='1 (or a UNION SELECT against another table) is no longer a search string. It is part of the statement. The same shape appears with Drizzle sql.raw(`...${q}...`), with db.execute(sql.raw(...)), and with a Postgres function that does EXECUTE 'SELECT ... WHERE x = ''' || p_q || ''''.
Prisma: $queryRaw is safe, $queryRawUnsafe is not
Prisma Client's raw queries docs expose four methods. Two are tagged templates that create prepared statements. Two take a raw string and are named Unsafe on purpose:
| Method | Use for | Injection risk |
|---|---|---|
$queryRaw |
SELECT that returns rows |
Low when used as a tagged template |
$executeRaw |
UPDATE / DELETE row counts |
Low when used as a tagged template |
$queryRawUnsafe |
Raw string SELECT |
High if any user input is concatenated |
$executeRawUnsafe |
Raw string writes | High if any user input is concatenated |
Safe pattern (tagged template; Prisma binds ${email}):
const email = requestEmail; // untrusted
const rows = await prisma.$queryRaw`
SELECT id, email FROM "User" WHERE email = ${email}
`;
Also safe: pass placeholders and values separately to $queryRawUnsafe only when you cannot use a tagged template, and the SQL string itself is a trusted constant:
const rows = await prisma.$queryRawUnsafe(
'SELECT id, email FROM "User" WHERE email = $1',
email,
);
Unsafe patterns Prisma documents explicitly:
// BAD: concatenation into the SQL string
const rows = await prisma.$queryRawUnsafe(
`SELECT id, email FROM "User" WHERE email = '${email}'`,
);
// BAD: Prisma.raw with untrusted text inside a "safe" tagged template
import { Prisma } from '@prisma/client';
const rows = await prisma.$queryRaw`
SELECT id, email FROM "User" WHERE email = ${Prisma.raw(`'${email}'`)}
`;
Prisma also warns that template variables cannot stand in for table names, column names, or SQL keywords. If you need a dynamic table, do not interpolate the request. Map it:
const tables = { users: '"User"', posts: '"Post"' } as const;
const key = requestTable as keyof typeof tables;
if (!(key in tables)) {
return new Response(null, { status: 400 });
}
// Still prefer the ORM. If you must go raw, only the allow-listed name enters the string.
const rows = await prisma.$queryRawUnsafe(
`SELECT id FROM ${tables[key]} WHERE id = $1`,
id,
);
Prefer the ORM for that case too: prisma.user.findMany({ where: { id } }) needs no raw SQL.
Drizzle: sql binds, sql.raw does not
Drizzle's sql operator docs say the tagged template maps dynamic values to $1-style placeholders and "effectively prevents any potential SQL Injection vulnerabilities." Their contrast for sql.raw() is the opposite: the value is inlined into the query with no parameterization.
import { sql } from 'drizzle-orm';
import { users } from './schema';
const id = Number(params.id);
// Safe: id becomes a bound parameter
await db.execute(sql`select * from ${users} where ${users.id} = ${id}`);
// BAD: id is pasted into the SQL text
await db.execute(sql.raw(`select * from users where id = ${id}`));
// BAD: raw fragment inside an otherwise safe template
await db.execute(sql`select * from ${users} where id = ${sql.raw(String(id))}`);
Use sql for fragments inside where, orderBy, and partial selects. Reach for sql.raw only with constants you control (a fixed NULLS FIRST clause you wrote, not a string from the client). For sort direction, map a boolean or enum to 'asc' / 'desc' in TypeScript, then use the query builder's asc() / desc() helpers instead of concatenating the word into SQL.
Supabase: query builder and rpc args are fine; function bodies are not
Client query builder
The Supabase JS query builder (.from().select().eq(), .ilike(), filters on rpc results) sends values through PostgREST as parameters. A username of ' OR '1'='1 is still one string to compare. Prefer that API for CRUD.
rpc with typed parameters
Calling a database function is normal and useful:
const { data, error } = await supabase.rpc('search_profiles', {
p_query: q,
});
That is safe when search_profiles uses p_query as a value:
create or replace function public.search_profiles(p_query text)
returns setof public.profiles
language sql
stable
security invoker
set search_path = ''
as $$
select *
from public.profiles
where username ilike '%' || p_query || '%'
and user_id = (select auth.uid());
$$;
Here p_query is still data. Postgres does not re-parse it as SQL. The || concatenation builds a pattern string, not a new statement.
The trap: dynamic SQL inside the function
The bug AI tools invent is a "run any filter" helper:
-- BAD: p_filter becomes part of the statement text
create or replace function public.run_filter(p_filter text)
returns setof public.profiles
language plpgsql
security definer
set search_path = ''
as $$
begin
return query execute
'select * from public.profiles where ' || p_filter;
end;
$$;
Anything the client passes as p_filter runs with the function owner's rights. On Supabase, Dashboard-created SECURITY DEFINER functions are often owned by postgres, which bypasses RLS. Supabase's database functions guide says to prefer security invoker, pin search_path, revoke execute from public/anon when the function is not meant for signed-out callers, and never treat an execute grant as a row filter.
If you truly need dynamic SQL, bind values with USING (PL/pgSQL plan execution):
create or replace function public.profiles_by_email(p_email text)
returns setof public.profiles
language plpgsql
security invoker
set search_path = ''
as $$
begin
return query execute
'select * from public.profiles where email = $1'
using p_email;
end;
$$;
For identifiers, do not concatenate the request. Allow-list in SQL or in TypeScript first. Postgres format('%I', name) quotes an identifier safely only after you have already rejected unexpected names.
To list every SECURITY DEFINER function in your project and who can execute it, use the queries in Supabase views and functions that bypass RLS.
Also avoid shipping a generic run_sql(sql text) RPC. That is remote code execution against your database with whatever role the function uses.
Next.js patterns that keep the hole closed
Route Handlers and Server Actions are public entry points. Next.js tells you to verify the caller inside them; the same rule applies to how you build queries.
// app/api/search/route.ts
import { prisma } from '@/lib/prisma';
import { auth } from '@/auth'; // your session helper
export async function GET(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return new Response(null, { status: 401 });
}
const q = new URL(request.url).searchParams.get('q') ?? '';
if (q.length > 100) {
return new Response(null, { status: 400 });
}
// Prefer the ORM:
const rows = await prisma.user.findMany({
where: {
id: session.user.id, // ownership, not only search
email: { contains: q, mode: 'insensitive' },
},
select: { id: true, email: true },
});
return Response.json(rows);
}
If you must use raw SQL, keep the statement text a constant and bind every value. Put the same discipline in 'use server' actions: no Prisma.raw(userInput), no sql.raw(userInput), no string-built filters passed to rpc.
Related hardening on the same handlers: Next.js Server Actions security, IDOR / owner filters, and the Supabase RLS checklist so a leaked query still cannot read every row.
Test checklist
- Repo search shows no
$queryRawUnsafe/$executeRawUnsafethat concatenate request data - No
Prisma.raw(...)wrapping untrusted strings - No Drizzle
sql.raw(...)with request, cookie, or header values - Every
$queryRaw/sql`...`use is a tagged template (or a trusted constant plus bound$1args) - Table, column, and sort direction come from an allow-list, never from the query string directly
- Supabase CRUD goes through
.from().select()...or typedrpcargs, not a client-supplied SQL string - Every Postgres function the app calls was read: no
EXECUTEwith concatenated values;USINGfor parameters -
SECURITY DEFINERfunctions setsearch_path, check ownership inside the body, and haveexecuterevoked from roles that should not call them - Secret /
service_rolekeys stay server-only (see anon vs service role) - A malicious search string (
' OR '1'='1, quote +UNION SELECT) returns zero rows or a 4xx, not other users' data - Automated review prompt below was run, and every flagged file was opened by a human
Then finish the ship checklist for a vibe-coded app before you share the URL.
Ask your AI tool to find it
Paste into Cursor, Bolt, Lovable, or v0 (do not paste secrets or production data):
Search this repo for Prisma $queryRawUnsafe, $executeRawUnsafe, Prisma.raw, Drizzle sql.raw, string-concatenated SQL, and supabase.rpc calls that pass a SQL string or a filter expression built from the request. For each hit, show whether values are bound as parameters or concatenated into SQL text. List Postgres migrations or SQL files that use EXECUTE with || or format(%s) on user-facing arguments. Flag SECURITY DEFINER functions and who has EXECUTE. Do not change code yet.
More review prompts live in the security review prompt guide.
Sources
- OWASP Cheat Sheet: SQL Injection Prevention
- Prisma: Raw queries ($queryRaw, $queryRawUnsafe, SQL injection prevention)
- Drizzle: Magical sql operator (sql vs sql.raw)
- Supabase: Database functions (rpc, security invoker/definer, privileges)
- Supabase JS: rpc
- Supabase: Row Level Security
- PostgreSQL: Executing dynamic commands (EXECUTE ... USING)
- Next.js: Data security
Questions
Does using Prisma or Drizzle mean I cannot get SQL injection?
No. The ORM query builders parameterize values for you. The holes are the raw escapes: Prisma $queryRawUnsafe and $executeRawUnsafe, Prisma.raw with untrusted text, Drizzle sql.raw, and any string you build with + or template concatenation before you run it. Prisma's own raw-query docs warn that Unsafe methods are at significant risk of SQL injection.
Is supabase.rpc() safe from SQL injection?
The call itself is. PostgREST binds the named arguments you pass in the second parameter as typed values, so a string like ' OR 1=1-- stays a string. The risk is inside the Postgres function: if the function builds dynamic SQL with string concatenation or EXECUTE without USING, the argument becomes injectable SQL. Prefer a plain SQL body that uses the parameters directly, or EXECUTE ... USING $1 for dynamic statements.
Can I parameterize a table or column name?
No. Bind parameters are for data values only. Prisma, Drizzle, and Postgres all say identifiers (table names, column names, ORDER BY direction) cannot go in placeholders. Map the user's choice to a fixed allow-list in your code, then put the trusted string into the query. OWASP calls that allow-list validation for the cases where bind variables are illegal.
Does Row Level Security stop SQL injection?
No. RLS limits which rows a role can see after a query runs. Injected SQL can still change the query's shape, read columns you never meant to expose, or run as a SECURITY DEFINER function that bypasses RLS. Keep parameterization and least privilege; treat RLS as a separate layer for authorization bugs like IDOR.
Where should I look first in a vibe-coded Next.js repo?
Search for $queryRawUnsafe, $executeRawUnsafe, Prisma.raw, sql.raw, .rpc(, EXECUTE, and any Route Handler or Server Action that builds SQL with + or nested template strings. Also open every Postgres function your app calls, especially SECURITY DEFINER ones. The checklist at the end of this guide is the short version.