Prisma ORM Security Audit
Audit Prisma ORM usage for vulnerabilities specific to its query model, raw query escape hatches, and common patterns developers get wrong.
When this skill applies
- Reviewing code using
@prisma/client
- Auditing
$queryRaw / $executeRaw / $queryRawUnsafe calls
- Reviewing mass assignment patterns in
create / update / upsert
- Checking tenant scoping across queries
- Reviewing
schema.prisma for missing constraints or indexes that have security implications
Use other skills for: generic IDOR/BOLA patterns (saas-security-pack/saas-code-security-review/references/idor-bola-patterns.md), tenant isolation (saas-security-pack/saas-tenant-isolation), backend framework specifics (nestjs-security, nodejs-express-security, nextjs-security).
Workflow
Follow ../_shared/audit-workflow.md. Prisma-specific notes below.
Phase 1: Stack detection
grep -E '"@prisma/client":' package.json
find . -name 'schema.prisma' -not -path '*/node_modules/*'
prisma --version 2>/dev/null
Phase 2: Inventory
# Raw query usage
grep -rnE '\$queryRaw|\$executeRaw|\$queryRawUnsafe|\$executeRawUnsafe' src/
# Model accesses
grep -rnE 'prisma\.[a-zA-Z]+\.(create|update|upsert|delete|findFirst|findMany|findUnique)' src/ | head -50
# Spread patterns (mass assignment risk)
grep -rnE 'data:\s*{?\s*\.\.\.' src/ | head -30
# Transaction / interactiveTransaction
grep -rnE '\$transaction|interactiveTransaction' src/
# Soft-delete or middleware
grep -rnE 'prisma\.\$(use|extends)' src/
Phase 3: Detection — the checks
Raw query injection
Prisma's parameterized queries are safe; the escape hatches are not.
- PRI-SQL-1
$queryRaw\...`(tagged template) — safe when used as designed:prisma.$queryRaw`SELECT * FROM users WHERE id = ${userId}`. The tagged template parameterizes ${...}`.
- PRI-SQL-2
$queryRawUnsafe(...) and $executeRawUnsafe(...) — NOT safe with user input. These accept a string + separate args; the string is concatenated. Audit every call:// BAD
await prisma.$queryRawUnsafe(`SELECT * FROM ${tableName} WHERE id = ${userId}`);
// GOOD
await prisma.$queryRaw`SELECT * FROM "Posts" WHERE id = ${userId}`;
// or, if table name truly dynamic:
const allowed = ['posts', 'comments', 'tags'];
if (!allowed.includes(tableName)) throw new Error('invalid table');
await prisma.$queryRawUnsafe(`SELECT * FROM "${tableName}" WHERE id = $1`, userId);
- PRI-SQL-3 Dynamic ORDER BY / LIMIT / column names in raw queries — Prisma's parameterization doesn't cover those; allowlist explicitly.
- PRI-SQL-4
Prisma.sql template literal helper — safe for composition: Prisma.sql`WHERE id = ${id} composed into larger queries.
// Composing safely
import { Prisma } from '@prisma/client';
const filters = [];
if (status) filters.push(Prisma.sql`status = ${status}`);
if (userId) filters.push(Prisma.sql`user_id = ${userId}`);
const whereClause = filters.length
? Prisma.sql`WHERE ${Prisma.join(filters, ' AND ')}`
: Prisma.empty;
await prisma.$queryRaw`SELECT * FROM "Posts" ${whereClause}`;
Mass assignment
Spreading user input into Prisma's data object is mass assignment.
- PRI-MA-1
data: { ...req.body } patterns — User can set any column they want, including role: 'admin', tenantId: 'other-tenant', verified: true, createdAt: '1970-01-01'.
- PRI-MA-2 Even with strict-typed inputs, if the input type matches the model type, user fields override server expectations.
// BAD
await prisma.user.create({ data: { ...req.body } });
// BAD even with parsing
const parsed = UserSchema.parse(req.body); // schema includes role, verified, ...
await prisma.user.create({ data: parsed });
// GOOD — explicit allow-list at the boundary
const { displayName, email, bio } = UserSchema.pick({
displayName: true, email: true, bio: true,
}).parse(req.body);
await prisma.user.create({
data: {
displayName,
email,
bio,
role: 'user', // server-controlled
tenantId: session.tenantId, // from session
},
});
- PRI-MA-3 Updates more dangerous than creates — an attacker can flip flags on their own record (e.g.,
data: { isVerified: true }).
- PRI-MA-4 Nested writes via
create and connect — data: { posts: { create: { ... } } } — also subject to mass assignment if user controls the nested object.
IDOR — missing scoping in queries
Prisma doesn't auto-scope. Every findUnique / findFirst / findMany / update / delete must include ownership check.
PRI-IDOR-1 findUnique({ where: { id: req.params.id } }) — finds any record by id. If returned to user, IDOR.
PRI-IDOR-2 update({ where: { id: req.params.id }, data: ... }) — same; updates any record.
PRI-IDOR-3 Correct pattern: compound where:
// BAD
await prisma.invoice.findUnique({ where: { id: invoiceId } });
// GOOD
await prisma.invoice.findFirst({
where: { id: invoiceId, tenantId: session.tenantId },
});
Note: findUnique requires the where to be a unique identifier; if you need a compound where with non-unique fields, use findFirst. For Prisma 5+: use findUniqueOrThrow / findFirstOrThrow for safer error handling.
PRI-IDOR-4 findMany without where → returns all rows. Always pass a tenant filter at minimum.
PRI-IDOR-5 Bulk operations (updateMany, deleteMany) — same scoping rule. prisma.post.updateMany({ where: { authorId: session.userId }, data: ... }).
Soft delete and visibility
- PRI-SD-1 Soft-delete columns (e.g.,
deletedAt) — queries don't automatically filter; add deletedAt: null to every where. Or use Prisma Client Extensions / middleware to apply globally.
- PRI-SD-2 Tombstone records returned to clients leak existence of deleted resources.
Sensitive fields in responses
- PRI-RES-1
passwordHash, mfaSecret, apiKeyHash, billing details, internal flags — never returned to clients. Use select to explicitly choose returned fields, or define DTO projections.// BAD — returns whole user including passwordHash
const user = await prisma.user.findUnique({ where: { id } });
return user;
// GOOD
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, displayName: true, email: true, createdAt: true },
});
- PRI-RES-2 Prisma Client Extensions can define safe-by-default projections; check for an extension that filters sensitive fields on
findMany / findFirst.
Database connection and secrets
- PRI-DB-1
DATABASE_URL not in client-side code or VITE_/NEXT_PUBLIC_ env vars.
- PRI-DB-2 Connection pool limits set (
?connection_limit=N in URL or via datasource config) — unbounded connections become DoS surface.
- PRI-DB-3 SSL required in production (
?sslmode=require or ?sslmode=verify-full).
- PRI-DB-4 Read replicas use the read-only role; not the migration-capable role.
- PRI-DB-5 Prisma Migrate's shadow database not on the production cluster (separate db needed for migrate dev / migrate diff).
Schema-level concerns
Open schema.prisma:
- PRI-SCH-1 Foreign keys defined (
onDelete: Cascade / Restrict / SetNull) — orphaned records become security issues (e.g., a post for a deleted user with a stale authorId).
- PRI-SCH-2 Required relations marked correctly — optional relations (
?) often hide cases where the application assumed presence.
- PRI-SCH-3 Indexes on columns used in WHERE — performance, but also DoS prevention (unindexed query on large table → table scan → resource exhaustion).
- PRI-SCH-4 Unique constraints on identifier columns (
@unique on email, slug, etc.) — without them, race conditions allow duplicates with security implications.
- PRI-SCH-5 Multi-tenant schemas: tenant_id columns on every shared table; foreign-key composite indexes including
tenant_id.
- PRI-SCH-6
@db.Text / @db.VarChar(N) — large unbounded text columns enable DoS via huge writes.
Prisma extensions and middleware
Use of Client Extensions / deprecated $use middleware can implement cross-cutting controls:
- PRI-EXT-1 Tenant-scoping extension that auto-injects
tenantId filter — good defense-in-depth.
- PRI-EXT-2 Logging extension that doesn't log sensitive fields (Prisma
log levels include query and params — confirm not enabled in production for queries containing PII).
- PRI-EXT-3 Soft-delete extension applied to all relevant models.
Prisma Accelerate / Data Proxy
- PRI-ACC-1 Prisma Accelerate uses a connection string with API key. Treat as a secret; rotate periodically; restrict by project.
- PRI-ACC-2 Accelerate caching — confirm cache strategy doesn't share between tenants. The
cacheStrategy is opt-in per query; ensure tenant context is in cache key.
- PRI-ACC-3 Edge runtime use (Vercel Edge, Cloudflare Workers) requires Accelerate or Data Proxy; verify the Edge bundle doesn't ship the direct DATABASE_URL.
Migrations
- PRI-MIG-1 Migrations applied via CI/CD with limited credentials (not the runtime app credential).
- PRI-MIG-2 Destructive migrations gated by approval; reviewed for accidental column drops on populated tables.
- PRI-MIG-3
prisma db push for production = bad practice (skips migration history).
Phase 4: Triage
Critical class examples:
$queryRawUnsafe with user-concatenated input
data: { ...req.body } on user-self updates including admin flags
- Every
findFirst / update / delete missing tenant scope
- Returning whole user records including password hashes
Phase 5: Report
Use ../_shared/findings-schema.md. Prefix IDs with PRI-.
References
references/raw-query-safety.md — Detailed $queryRaw vs $queryRawUnsafe patterns and Prisma.sql composition
1---2name: prisma-orm-security3description: Security audit specific to Prisma ORM usage including raw query escape hatches ($queryRaw, $executeRaw, $queryRawUnsafe), mass assignment via spreading user input into create/update, missing tenant scoping on findFirst/findMany, IDOR through Prisma query construction, schema-level access control gaps, and Prisma Accelerate/Pulse security considerations. Use this skill whenever the user mentions Prisma, prisma/client, schema.prisma, $queryRaw, the prisma.modelName.create / update / findMany pattern, or asks "audit my Prisma queries", "Prisma security review", "raw query safety", "Prisma mass assignment". Trigger when the codebase contains @prisma/client, schema.prisma, or any prisma. query calls.4---56# Prisma ORM Security Audit78Audit Prisma ORM usage for vulnerabilities specific to its query model, raw query escape hatches, and common patterns developers get wrong.910## When this skill applies1112- Reviewing code using `@prisma/client`13- Auditing `$queryRaw` / `$executeRaw` / `$queryRawUnsafe` calls14- Reviewing mass assignment patterns in `create` / `update` / `upsert`15- Checking tenant scoping across queries16- Reviewing `schema.prisma` for missing constraints or indexes that have security implications1718Use other skills for: generic IDOR/BOLA patterns (`saas-security-pack/saas-code-security-review/references/idor-bola-patterns.md`), tenant isolation (`saas-security-pack/saas-tenant-isolation`), backend framework specifics (`nestjs-security`, `nodejs-express-security`, `nextjs-security`).1920## Workflow2122Follow `../_shared/audit-workflow.md`. Prisma-specific notes below.2324### Phase 1: Stack detection2526```bash27grep -E '"@prisma/client":' package.json28find . -name 'schema.prisma' -not -path '*/node_modules/*'29prisma --version 2>/dev/null30```3132### Phase 2: Inventory3334```bash35# Raw query usage36grep -rnE '\$queryRaw|\$executeRaw|\$queryRawUnsafe|\$executeRawUnsafe' src/3738# Model accesses39grep -rnE 'prisma\.[a-zA-Z]+\.(create|update|upsert|delete|findFirst|findMany|findUnique)' src/ | head -504041# Spread patterns (mass assignment risk)42grep -rnE 'data:\s*{?\s*\.\.\.' src/ | head -304344# Transaction / interactiveTransaction45grep -rnE '\$transaction|interactiveTransaction' src/4647# Soft-delete or middleware48grep -rnE 'prisma\.\$(use|extends)' src/49```5051### Phase 3: Detection — the checks5253#### Raw query injection5455Prisma's parameterized queries are safe; the escape hatches are not.5657- **PRI-SQL-1** `$queryRaw\`...\`` (tagged template) — safe when used as designed: `prisma.$queryRaw\`SELECT * FROM users WHERE id = ${userId}\``. The tagged template parameterizes `${...}`.58- **PRI-SQL-2** `$queryRawUnsafe(...)` and `$executeRawUnsafe(...)` — NOT safe with user input. These accept a string + separate args; the string is concatenated. Audit every call:59 ```ts60 // BAD61 await prisma.$queryRawUnsafe(`SELECT * FROM ${tableName} WHERE id = ${userId}`);62 63 // GOOD64 await prisma.$queryRaw`SELECT * FROM "Posts" WHERE id = ${userId}`;65 // or, if table name truly dynamic:66 const allowed = ['posts', 'comments', 'tags'];67 if (!allowed.includes(tableName)) throw new Error('invalid table');68 await prisma.$queryRawUnsafe(`SELECT * FROM "${tableName}" WHERE id = $1`, userId);69 ```70- **PRI-SQL-3** Dynamic ORDER BY / LIMIT / column names in raw queries — Prisma's parameterization doesn't cover those; allowlist explicitly.71- **PRI-SQL-4** `Prisma.sql` template literal helper — safe for composition: ``Prisma.sql`WHERE id = ${id}`` composed into larger queries.7273```ts74// Composing safely75import { Prisma } from '@prisma/client';7677const filters = [];78if (status) filters.push(Prisma.sql`status = ${status}`);79if (userId) filters.push(Prisma.sql`user_id = ${userId}`);80const whereClause = filters.length81 ? Prisma.sql`WHERE ${Prisma.join(filters, ' AND ')}`82 : Prisma.empty;8384await prisma.$queryRaw`SELECT * FROM "Posts" ${whereClause}`;85```8687#### Mass assignment8889Spreading user input into Prisma's `data` object is mass assignment.9091- **PRI-MA-1** `data: { ...req.body }` patterns — User can set any column they want, including `role: 'admin'`, `tenantId: 'other-tenant'`, `verified: true`, `createdAt: '1970-01-01'`.92- **PRI-MA-2** Even with strict-typed inputs, if the input type matches the model type, user fields override server expectations.9394```ts95// BAD96await prisma.user.create({ data: { ...req.body } });9798// BAD even with parsing99const parsed = UserSchema.parse(req.body); // schema includes role, verified, ...100await prisma.user.create({ data: parsed });101102// GOOD — explicit allow-list at the boundary103const { displayName, email, bio } = UserSchema.pick({104 displayName: true, email: true, bio: true,105}).parse(req.body);106107await prisma.user.create({108 data: {109 displayName,110 email,111 bio,112 role: 'user', // server-controlled113 tenantId: session.tenantId, // from session114 },115});116```117118- **PRI-MA-3** Updates more dangerous than creates — an attacker can flip flags on their own record (e.g., `data: { isVerified: true }`).119- **PRI-MA-4** Nested writes via `create` and `connect` — `data: { posts: { create: { ... } } }` — also subject to mass assignment if user controls the nested object.120121#### IDOR — missing scoping in queries122123Prisma doesn't auto-scope. Every `findUnique` / `findFirst` / `findMany` / `update` / `delete` must include ownership check.124125- **PRI-IDOR-1** `findUnique({ where: { id: req.params.id } })` — finds any record by id. If returned to user, IDOR.126- **PRI-IDOR-2** `update({ where: { id: req.params.id }, data: ... })` — same; updates any record.127- **PRI-IDOR-3** Correct pattern: compound where:128 ```ts129 // BAD130 await prisma.invoice.findUnique({ where: { id: invoiceId } });131 132 // GOOD133 await prisma.invoice.findFirst({134 where: { id: invoiceId, tenantId: session.tenantId },135 });136 ```137 Note: `findUnique` requires the where to be a unique identifier; if you need a compound where with non-unique fields, use `findFirst`. For Prisma 5+: use `findUniqueOrThrow` / `findFirstOrThrow` for safer error handling.138139- **PRI-IDOR-4** `findMany` without where → returns all rows. Always pass a tenant filter at minimum.140- **PRI-IDOR-5** Bulk operations (`updateMany`, `deleteMany`) — same scoping rule. `prisma.post.updateMany({ where: { authorId: session.userId }, data: ... })`.141142#### Soft delete and visibility143144- **PRI-SD-1** Soft-delete columns (e.g., `deletedAt`) — queries don't automatically filter; add `deletedAt: null` to every where. Or use Prisma Client Extensions / middleware to apply globally.145- **PRI-SD-2** Tombstone records returned to clients leak existence of deleted resources.146147#### Sensitive fields in responses148149- **PRI-RES-1** `passwordHash`, `mfaSecret`, `apiKeyHash`, billing details, internal flags — never returned to clients. Use `select` to explicitly choose returned fields, or define DTO projections.150 ```ts151 // BAD — returns whole user including passwordHash152 const user = await prisma.user.findUnique({ where: { id } });153 return user;154 155 // GOOD156 const user = await prisma.user.findUnique({157 where: { id },158 select: { id: true, displayName: true, email: true, createdAt: true },159 });160 ```161- **PRI-RES-2** Prisma Client Extensions can define safe-by-default projections; check for an extension that filters sensitive fields on `findMany` / `findFirst`.162163#### Database connection and secrets164165- **PRI-DB-1** `DATABASE_URL` not in client-side code or `VITE_`/`NEXT_PUBLIC_` env vars.166- **PRI-DB-2** Connection pool limits set (`?connection_limit=N` in URL or via `datasource` config) — unbounded connections become DoS surface.167- **PRI-DB-3** SSL required in production (`?sslmode=require` or `?sslmode=verify-full`).168- **PRI-DB-4** Read replicas use the read-only role; not the migration-capable role.169- **PRI-DB-5** Prisma Migrate's shadow database not on the production cluster (separate db needed for migrate dev / migrate diff).170171#### Schema-level concerns172173Open `schema.prisma`:174175- **PRI-SCH-1** Foreign keys defined (`onDelete: Cascade` / `Restrict` / `SetNull`) — orphaned records become security issues (e.g., a post for a deleted user with a stale `authorId`).176- **PRI-SCH-2** Required relations marked correctly — optional relations (`?`) often hide cases where the application assumed presence.177- **PRI-SCH-3** Indexes on columns used in WHERE — performance, but also DoS prevention (unindexed query on large table → table scan → resource exhaustion).178- **PRI-SCH-4** Unique constraints on identifier columns (`@unique` on `email`, `slug`, etc.) — without them, race conditions allow duplicates with security implications.179- **PRI-SCH-5** Multi-tenant schemas: tenant_id columns on every shared table; foreign-key composite indexes including `tenant_id`.180- **PRI-SCH-6** `@db.Text` / `@db.VarChar(N)` — large unbounded text columns enable DoS via huge writes.181182#### Prisma extensions and middleware183184Use of Client Extensions / deprecated `$use` middleware can implement cross-cutting controls:185186- **PRI-EXT-1** Tenant-scoping extension that auto-injects `tenantId` filter — good defense-in-depth.187- **PRI-EXT-2** Logging extension that doesn't log sensitive fields (Prisma `log` levels include query and params — confirm not enabled in production for queries containing PII).188- **PRI-EXT-3** Soft-delete extension applied to all relevant models.189190#### Prisma Accelerate / Data Proxy191192- **PRI-ACC-1** Prisma Accelerate uses a connection string with API key. Treat as a secret; rotate periodically; restrict by project.193- **PRI-ACC-2** Accelerate caching — confirm cache strategy doesn't share between tenants. The `cacheStrategy` is opt-in per query; ensure tenant context is in cache key.194- **PRI-ACC-3** Edge runtime use (Vercel Edge, Cloudflare Workers) requires Accelerate or Data Proxy; verify the Edge bundle doesn't ship the direct DATABASE_URL.195196#### Migrations197198- **PRI-MIG-1** Migrations applied via CI/CD with limited credentials (not the runtime app credential).199- **PRI-MIG-2** Destructive migrations gated by approval; reviewed for accidental column drops on populated tables.200- **PRI-MIG-3** `prisma db push` for production = bad practice (skips migration history).201202### Phase 4: Triage203204Critical class examples:205- `$queryRawUnsafe` with user-concatenated input206- `data: { ...req.body }` on user-self updates including admin flags207- Every `findFirst` / `update` / `delete` missing tenant scope208- Returning whole user records including password hashes209210### Phase 5: Report211212Use `../_shared/findings-schema.md`. Prefix IDs with `PRI-`.213214## References215216- `references/raw-query-safety.md` — Detailed `$queryRaw` vs `$queryRawUnsafe` patterns and Prisma.sql composition