How to Secure Drizzle ORM
Type-safe database queries with security best practices
TL;DR
TL;DR (20 minutes): Drizzle's query builder is SQL-injection safe. For raw SQL, always use the sql template tag. Validate inputs with Zod, implement access control by adding user filters to queries, and use Drizzle's select() to control which fields are returned.
Prerequisites:
- Drizzle ORM installed in your project
- Basic understanding of TypeScript
- PostgreSQL, MySQL, or SQLite database
Why This Matters
Drizzle is designed to be type-safe and SQL-injection resistant, but security still requires proper implementation. This guide covers how to use Drizzle's features safely and avoid common pitfalls.
Step-by-Step Guide
Understand Drizzle's built-in safety
Drizzle's query builder automatically parameterizes values:
import { eq, and, like, sql } from 'drizzle-orm';
import { db } from './db';
import { users, posts } from './schema';
// SAFE - Values are automatically parameterized
const user = await db.select()
.from(users)
.where(eq(users.email, userInput));
// SAFE - Multiple conditions
const results = await db.select()
.from(posts)
.where(and(
eq(posts.userId, userId),
like(posts.title, `%${searchTerm}%`)
));
// SAFE - Joins and complex queries
const userPosts = await db.select({
postId: posts.id,
title: posts.title,
userName: users.name
})
.from(posts)
.innerJoin(users, eq(posts.userId, users.id))
.where(eq(posts.status, 'published'));
Use the sql template tag for raw queries
import { sql } from 'drizzle-orm';
// DANGEROUS - String interpolation in raw SQL
const bad = await db.execute(
`SELECT * FROM users WHERE email = '${userInput}'` // SQL INJECTION!
);
// SAFE - Using sql template tag
const good = await db.execute(
sql`SELECT * FROM users WHERE email = ${userInput}`
);
// SAFE - Building dynamic queries with sql
const searchUsers = async (searchTerm: string, sortBy: string) => {
// Validate sortBy against allowlist
const allowedSorts = ['created_at', 'name', 'email'] as const;
if (!allowedSorts.includes(sortBy as any)) {
throw new Error('Invalid sort column');
}
// sql.raw() for trusted values only (like validated column names)
return db.execute(sql`
SELECT id, name, email
FROM users
WHERE name ILIKE ${`%${searchTerm}%`}
ORDER BY ${sql.raw(sortBy)} DESC
LIMIT 100
`);
};
// SAFE - Conditional query building
const buildQuery = (filters: { email?: string; status?: string }) => {
let query = sql`SELECT * FROM users WHERE 1=1`;
if (filters.email) {
query = sql`${query} AND email = ${filters.email}`;
}
if (filters.status) {
query = sql`${query} AND status = ${filters.status}`;
}
return db.execute(query);
};
Validate inputs with Zod
import { z } from 'zod';
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
import { users } from './schema';
// Generate Zod schema from Drizzle schema
const insertUserSchema = createInsertSchema(users, {
email: z.string().email(),
name: z.string().min(1).max(100),
// Override or add custom validations
});
const selectUserSchema = createSelectSchema(users);
// Use in your API handlers
async function createUser(input: unknown) {
// Validate input
const data = insertUserSchema.parse(input);
// Safe to use with Drizzle
return db.insert(users).values(data).returning();
}
// Custom schemas for specific operations
const updateUserSchema = z.object({
name: z.string().min(1).max(100).optional(),
bio: z.string().max(500).optional(),
}).refine(data => Object.keys(data).length > 0, {
message: 'At least one field must be provided'
});
async function updateUser(id: string, input: unknown) {
const userId = z.string().uuid().parse(id);
const data = updateUserSchema.parse(input);
return db.update(users)
.set(data)
.where(eq(users.id, userId))
.returning();
}
Implement access control
import { and, eq } from 'drizzle-orm';
// Create a wrapper that enforces user access
function createUserScopedDb(userId: string) {
return {
// Orders - user can only see their own
orders: {
findMany: () =>
db.select()
.from(orders)
.where(eq(orders.userId, userId)),
findOne: (orderId: string) =>
db.select()
.from(orders)
.where(and(
eq(orders.id, orderId),
eq(orders.userId, userId) // Always include user filter
))
.limit(1),
create: (data: NewOrder) =>
db.insert(orders)
.values({ ...data, userId }) // Always set userId
.returning(),
update: (orderId: string, data: Partial) =>
db.update(orders)
.set(data)
.where(and(
eq(orders.id, orderId),
eq(orders.userId, userId) // User can only update their own
))
.returning()
}
};
}
// Usage in your API
async function getOrders(req: Request) {
const userId = req.user.id;
const userDb = createUserScopedDb(userId);
return userDb.orders.findMany();
}
// Admin bypass for privileged operations
function createAdminDb() {
return {
orders: {
findMany: (filters?: { userId?: string; status?: string }) =>
db.select()
.from(orders)
.where(filters?.userId ? eq(orders.userId, filters.userId) : undefined)
}
};
}
Control field exposure
import { users } from './schema';
// Define what fields to expose publicly
const publicUserFields = {
id: users.id,
name: users.name,
avatar: users.avatar,
createdAt: users.createdAt
};
// Exclude sensitive fields
async function getPublicProfile(userId: string) {
const result = await db
.select(publicUserFields) // Only select public fields
.from(users)
.where(eq(users.id, userId))
.limit(1);
return result[0];
}
// For the user viewing their own profile
const privateUserFields = {
...publicUserFields,
email: users.email,
settings: users.settings
};
async function getMyProfile(userId: string) {
const result = await db
.select(privateUserFields)
.from(users)
.where(eq(users.id, userId))
.limit(1);
return result[0];
}
// Never expose these fields
// - passwordHash
// - resetToken
// - verificationToken
// - internalNotes
Secure connection management
// db.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema';
// Validate environment variables
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL is required');
}
// Create connection pool with security settings
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production'
? { rejectUnauthorized: true }
: false,
max: 10, // Limit connections
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000
});
export const db = drizzle(pool, { schema });
// For serverless, use connection pooling
// Neon example
import { neon } from '@neondatabase/serverless';
import { drizzle } from 'drizzle-orm/neon-http';
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql, { schema });
Implement query limits and pagination
// Always limit query results
const MAX_LIMIT = 100;
const DEFAULT_LIMIT = 20;
interface PaginationParams {
page?: number;
limit?: number;
}
function getPagination(params: PaginationParams) {
const page = Math.max(1, params.page || 1);
const limit = Math.min(MAX_LIMIT, Math.max(1, params.limit || DEFAULT_LIMIT));
const offset = (page - 1) * limit;
return { limit, offset };
}
async function getUsers(params: PaginationParams) {
const { limit, offset } = getPagination(params);
const results = await db.select()
.from(users)
.limit(limit)
.offset(offset)
.orderBy(users.createdAt);
// Get total count for pagination
const [{ count }] = await db
.select({ count: sql`count(*)` })
.from(users);
return {
data: results,
pagination: {
page: Math.floor(offset / limit) + 1,
limit,
total: Number(count),
pages: Math.ceil(Number(count) / limit)
}
};
}
Drizzle Security Checklist:
- Always use
sqltemplate tag for raw SQL - Never use
sql.raw()with user input - Validate all inputs before using in queries
- Add user filters for multi-tenant data
- Use select() to control returned fields
- Always limit query results
- Store DATABASE_URL in environment variables
How to Verify It Worked
- Test SQL injection payloads:
const maliciousInputs = [
"'; DROP TABLE users; --",
"1 OR 1=1",
"admin'--"
];
for (const input of maliciousInputs) {
const result = await db.select()
.from(users)
.where(eq(users.email, input));
// Should return empty array, not error or all users
}
- Test access control: Try accessing another user's data
- Check field exposure: Review API responses for sensitive fields
Common Errors & Troubleshooting
"column does not exist" in raw queries
Column names in sql template are case-sensitive. Use quotes for mixed-case columns: sql.raw('"userId"')
Type errors with sql template
Use type parameter: sql<number>count(*)`` for correct TypeScript types.
Connection pool exhaustion
Ensure you're reusing the db instance, not creating new connections per request.
Is Drizzle as safe as Prisma?
Both are safe when used correctly. Drizzle gives you more control over raw SQL, which means more opportunity for mistakes. Use the sql template tag consistently.
Can I use dynamic column names safely?
Yes, but only with an allowlist. Validate the column name against a list of allowed values, then use sql.raw() for the validated value.
How do I handle soft deletes securely?
Add a default filter for deletedAt IS NULL to all queries. Consider using a wrapper function that always includes this condition.
Related guides:Prisma Security · Parameterized Queries · Zod Validation