How to Secure Prisma ORM

Share
How-To Guide

How to Secure Prisma ORM

Security best practices for modern TypeScript applications

TL;DR

TL;DR (20 minutes): Prisma's query builder is safe from SQL injection by default. The danger is in raw queries: always use $queryRaw with template literals, never $queryRawUnsafe with user input. Add middleware for access control, validate all inputs with Zod, and never expose your full database schema to clients.

Prerequisites:

  • Prisma ORM installed in your project
  • Basic understanding of TypeScript
  • Familiarity with SQL injection risks

Why This Matters

Prisma is one of the safest ORMs available - its query builder automatically prevents SQL injection. However, developers often introduce vulnerabilities through raw queries, improper access control, or data exposure. This guide covers the security pitfalls specific to Prisma.

Step-by-Step Guide

1

Understand Prisma's built-in safety

Prisma Client methods are safe from SQL injection:

// SAFE - Prisma automatically parameterizes
const user = await prisma.user.findUnique({
  where: { email: userInput }  // userInput is safely escaped
});

const users = await prisma.user.findMany({
  where: {
    OR: [
      { name: { contains: searchTerm } },
      { email: { contains: searchTerm } }
    ]
  }
});

// Even complex queries are safe
const orders = await prisma.order.findMany({
  where: {
    userId: userId,
    status: { in: ['pending', 'processing'] },
    createdAt: { gte: new Date(startDate) }
  }
});
2

Handle raw queries safely

Raw queries are where vulnerabilities creep in:

// DANGEROUS - SQL injection vulnerability!
const result = await prisma.$queryRawUnsafe(
  `SELECT * FROM users WHERE email = '${userInput}'`
);

// SAFE - Use template literal with $queryRaw
const result = await prisma.$queryRaw`
  SELECT * FROM users WHERE email = ${userInput}
`;

// SAFE - For dynamic queries, use Prisma.sql
import { Prisma } from '@prisma/client';

const columns = ['id', 'name', 'email'];
const safeColumns = columns
  .filter(col => ['id', 'name', 'email'].includes(col))
  .map(col => Prisma.raw(col));

const result = await prisma.$queryRaw`
  SELECT ${Prisma.join(safeColumns)} FROM users
  WHERE id = ${userId}
`;

// For dynamic table names - use allowlist
const ALLOWED_TABLES = ['users', 'posts', 'comments'];
function getTable(tableName: string) {
  if (!ALLOWED_TABLES.includes(tableName)) {
    throw new Error('Invalid table name');
  }
  return Prisma.raw(tableName);
}
3

Validate inputs before queries

import { z } from 'zod';

// Define input schemas
const CreateUserSchema = z.object({
  email: z.string().email().max(255),
  name: z.string().min(1).max(100),
  role: z.enum(['user', 'admin']).default('user')
});

const UserIdSchema = z.string().uuid();

// Validate in your API handlers
async function createUser(input: unknown) {
  // Validate and sanitize input
  const data = CreateUserSchema.parse(input);

  // Now safe to use with Prisma
  return prisma.user.create({ data });
}

async function getUser(id: unknown) {
  const userId = UserIdSchema.parse(id);
  return prisma.user.findUnique({
    where: { id: userId }
  });
}

// Validate query parameters
const ListUsersSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  sortBy: z.enum(['createdAt', 'name', 'email']).default('createdAt'),
  sortOrder: z.enum(['asc', 'desc']).default('desc')
});
4

Implement access control middleware

import { PrismaClient, Prisma } from '@prisma/client';

const prisma = new PrismaClient();

// Middleware to enforce access control
prisma.$use(async (params, next) => {
  const context = getRequestContext(); // Your auth context

  // Enforce user can only access their own data
  if (params.model === 'Order') {
    if (params.action === 'findMany' || params.action === 'findFirst') {
      params.args.where = {
        ...params.args.where,
        userId: context.userId  // Always filter by current user
      };
    }
    if (params.action === 'update' || params.action === 'delete') {
      params.args.where = {
        ...params.args.where,
        userId: context.userId
      };
    }
  }

  // Prevent deletion of audit logs
  if (params.model === 'AuditLog' && params.action === 'delete') {
    throw new Error('Audit logs cannot be deleted');
  }

  return next(params);
});

// Or use Prisma Client Extensions (newer approach)
const prismaWithAuth = prisma.$extends({
  query: {
    order: {
      async findMany({ args, query }) {
        const ctx = getRequestContext();
        args.where = { ...args.where, userId: ctx.userId };
        return query(args);
      }
    }
  }
});
5

Control field exposure

// DON'T return entire user objects to clients
async function getUser(id: string) {
  const user = await prisma.user.findUnique({
    where: { id }
  });
  return user; // Exposes password hash, internal fields, etc.
}

// DO select only needed fields
async function getUser(id: string) {
  return prisma.user.findUnique({
    where: { id },
    select: {
      id: true,
      name: true,
      email: true,
      createdAt: true
      // password, internal fields excluded
    }
  });
}

// Create reusable select objects
const publicUserSelect = {
  id: true,
  name: true,
  email: true,
  avatar: true,
  createdAt: true
} satisfies Prisma.UserSelect;

const privateUserSelect = {
  ...publicUserSelect,
  role: true,
  settings: true
} satisfies Prisma.UserSelect;

// Use in queries
const user = await prisma.user.findUnique({
  where: { id },
  select: isAdmin ? privateUserSelect : publicUserSelect
});
6

Secure connection string handling

// schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// NEVER do this:
// url = "postgresql://user:password@host:5432/db"

// For serverless with connection pooling
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")       // Pooled connection
  directUrl = env("DIRECT_DATABASE_URL") // For migrations
}

// .env (local development only)
DATABASE_URL="postgresql://..."

// Production: Set in your platform's environment variables
// Never commit .env files with real credentials

// Validate environment variables at startup
import { z } from 'zod';

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  NODE_ENV: z.enum(['development', 'production', 'test'])
});

envSchema.parse(process.env);
7

Implement query limits and pagination

// DANGEROUS - could return millions of rows
const allUsers = await prisma.user.findMany();

// SAFE - Always limit results
const users = await prisma.user.findMany({
  take: 100,  // Maximum records to return
  skip: 0     // Offset for pagination
});

// Implement cursor-based pagination (more efficient)
async function getUsers(cursor?: string, limit = 20) {
  const users = await prisma.user.findMany({
    take: limit + 1, // Fetch one extra to check if there's more
    ...(cursor && {
      cursor: { id: cursor },
      skip: 1 // Skip the cursor itself
    }),
    orderBy: { createdAt: 'desc' }
  });

  const hasMore = users.length > limit;
  const data = hasMore ? users.slice(0, -1) : users;
  const nextCursor = hasMore ? data[data.length - 1].id : null;

  return { data, nextCursor, hasMore };
}

// Enforce maximum limits
const MAX_LIMIT = 100;
function getLimit(requested?: number): number {
  if (!requested || requested < 1) return 20;
  return Math.min(requested, MAX_LIMIT);
}

Common Prisma Security Mistakes:

  • Using $queryRawUnsafe with any user input
  • Exposing internal fields (password hashes, tokens) in API responses
  • No pagination on findMany queries
  • Missing access control - users can access other users' data
  • Committing .env files with database credentials
  • Using the same database user for app and migrations

How to Verify It Worked

  1. Test for SQL injection: Try malicious inputs in your API
  2. Test access control: Verify users can't access others' data
  3. Check field exposure: Review API responses for sensitive data
  4. Test pagination: Ensure large queries are limited
// Test with malicious inputs
const testInputs = [
  "'; DROP TABLE users; --",
  "1 OR 1=1",
  "admin'--",
  "1; SELECT * FROM users"
];

for (const input of testInputs) {
  const result = await api.searchUsers({ query: input });
  // Should return empty results, not errors or all data
}

Common Errors & Troubleshooting

Raw query returning unexpected results

Check if you're using template literals correctly. $queryRaw with backticks parameterizes; string concatenation doesn't.

Middleware not running

Ensure middleware is registered before queries. Use $use immediately after creating PrismaClient.

"Cannot read property 'where' of undefined"

Some operations don't have args. Check params.args exists before modifying.

Select and include conflict

You can't use both select and include. Choose one or restructure your query.

Is Prisma safe from SQL injection by default?

Yes, for all Prisma Client methods (findMany, create, update, etc.). Only raw queries ($queryRaw, $executeRaw) require care - always use template literals, never string concatenation.

Should I use Prisma middleware or Client Extensions?

Client Extensions (introduced in Prisma 4.16) are the newer, more type-safe approach. Use them for new projects. Middleware still works and is fine for existing projects.

How do I implement row-level security with Prisma?

Use middleware to automatically add user filters to queries. For stronger guarantees, implement RLS in your database (PostgreSQL) and use Prisma with a user-scoped connection.

Related guides:Drizzle Security · Parameterized Queries · Zod Validation

How-To Guides

How to Secure Prisma ORM