Drizzle ORM Security Guide for Vibe Coders
Published on January 23, 2026 - 10 min read
TL;DR
Drizzle ORM provides type-safe database queries that automatically prevent SQL injection when used correctly. Use the query builder methods (select(), insert(), etc.) for automatic parameterization. When using sql template literals, values are automatically escaped. Avoid string concatenation in queries. Validate all user input with Zod before it reaches your database layer.
Why Drizzle Security Matters for Vibe Coding
Drizzle ORM is a lightweight, TypeScript-first ORM that's gained popularity for its performance and type safety. When AI tools generate Drizzle code, they typically produce safe query builder syntax, but they can also generate unsafe patterns when asked to build dynamic queries or use raw SQL.
The good news: Drizzle's design makes secure patterns the default. The challenge is recognizing when you've drifted into unsafe territory.
Safe Query Patterns
Drizzle's query builder automatically parameterizes values, making these patterns safe:
Select Queries
import { db } from './db';
import { users, posts } from './schema';
import { eq, and, like, gt } from 'drizzle-orm';
// SAFE: All values are automatically parameterized
const user = await db
.select()
.from(users)
.where(eq(users.email, userInput));
// SAFE: Multiple conditions
const activePosts = await db
.select()
.from(posts)
.where(
and(
eq(posts.authorId, userId),
eq(posts.status, 'published'),
gt(posts.views, 100)
)
);
// SAFE: Pattern matching with LIKE
const searchResults = await db
.select()
.from(users)
.where(like(users.name, `%${searchTerm}%`));
Insert and Update
// SAFE: Insert with values
const newUser = await db
.insert(users)
.values({
email: userInput.email,
name: userInput.name,
passwordHash: hashedPassword,
})
.returning();
// SAFE: Update with conditions
await db
.update(users)
.set({ name: newName, updatedAt: new Date() })
.where(eq(users.id, userId));
// SAFE: Delete with conditions
await db
.delete(posts)
.where(
and(
eq(posts.id, postId),
eq(posts.authorId, userId) // Ensure user owns the post
)
);
SQL Template Literals
Drizzle's sql template literal automatically escapes interpolated values:
import { sql } from 'drizzle-orm';
// SAFE: Template literal parameterizes automatically
const results = await db.execute(
sql`SELECT * FROM users WHERE email = ${email}`
);
// SAFE: Complex queries with multiple values
const stats = await db.execute(
sql`
SELECT
COUNT(*) as total,
SUM(CASE WHEN status = ${status} THEN 1 ELSE 0 END) as matching
FROM posts
WHERE author_id = ${authorId}
`
);
Dangerous: Raw String Concatenation
// DANGEROUS: String concatenation bypasses parameterization
const results = await db.execute(
sql.raw(`SELECT * FROM users WHERE email = '${email}'`)
);
// DANGEROUS: Building SQL strings manually
const query = `SELECT * FROM ${tableName} WHERE id = ${id}`;
await db.execute(sql.raw(query));
The sql.raw() function passes strings directly to the database without escaping. Never use it with user input.
Dynamic Column Selection
Dynamic queries require extra care to prevent injection:
// DANGEROUS: Dynamic column from user input
const column = userInput.sortBy; // Could be "id; DROP TABLE users;"
const results = await db.execute(
sql.raw(`SELECT * FROM users ORDER BY ${column}`)
);
// SAFE: Allowlist approach
const ALLOWED_SORT_COLUMNS = ['id', 'name', 'email', 'createdAt'] as const;
type SortColumn = typeof ALLOWED_SORT_COLUMNS[number];
function getSortColumn(input: string): SortColumn {
if (ALLOWED_SORT_COLUMNS.includes(input as SortColumn)) {
return input as SortColumn;
}
return 'id'; // Default fallback
}
// Now safe to use
const sortColumn = getSortColumn(userInput.sortBy);
const results = await db
.select()
.from(users)
.orderBy(users[sortColumn]);
Dynamic Table Selection
// SAFE: Use schema objects instead of strings
import * as schema from './schema';
const ALLOWED_TABLES = {
users: schema.users,
posts: schema.posts,
comments: schema.comments,
} as const;
function getTable(tableName: string) {
if (tableName in ALLOWED_TABLES) {
return ALLOWED_TABLES[tableName as keyof typeof ALLOWED_TABLES];
}
throw new Error('Invalid table name');
}
// Usage
const table = getTable(userInput.tableName);
const results = await db.select().from(table);
Input Validation with Zod
Validate all user input before it reaches your database queries:
import { z } from 'zod';
// Define validation schemas
const CreateUserSchema = z.object({
email: z.string().email().max(255),
name: z.string().min(1).max(100),
password: z.string().min(8).max(100),
});
const SearchQuerySchema = z.object({
query: z.string().max(100),
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.enum(['name', 'email', 'createdAt']).default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
});
// In your API route
export async function POST(request: Request) {
const body = await request.json();
// Validate before any database operation
const result = CreateUserSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ error: 'Validation failed', details: result.error.flatten() },
{ status: 400 }
);
}
// Now safe to use - values are validated and typed
const { email, name, password } = result.data;
const user = await db
.insert(users)
.values({
email,
name,
passwordHash: await hash(password),
})
.returning();
return Response.json(user);
}
Preventing Mass Assignment
Control which fields can be updated:
// DANGEROUS: Passing user input directly
await db.update(users).set(userInput).where(eq(users.id, userId));
// User could set: { isAdmin: true, balance: 999999 }
// SAFE: Explicitly select allowed fields
const UpdateProfileSchema = z.object({
name: z.string().min(1).max(100).optional(),
bio: z.string().max(500).optional(),
avatarUrl: z.string().url().optional(),
});
export async function updateProfile(userId: string, input: unknown) {
const result = UpdateProfileSchema.safeParse(input);
if (!result.success) {
throw new Error('Invalid input');
}
// Only validated fields can be updated
await db
.update(users)
.set(result.data)
.where(eq(users.id, userId));
}
Transaction Security
Use transactions for operations that must be atomic:
// Transfer funds with proper transaction handling
async function transferFunds(
fromUserId: string,
toUserId: string,
amount: number
) {
return await db.transaction(async (tx) => {
// Lock the rows being modified (if your DB supports it)
const [fromAccount] = await tx
.select()
.from(accounts)
.where(eq(accounts.userId, fromUserId))
.for('update');
if (!fromAccount || fromAccount.balance < amount) {
throw new Error('Insufficient funds');
}
// Debit from source
await tx
.update(accounts)
.set({ balance: sql`${accounts.balance} - ${amount}` })
.where(eq(accounts.userId, fromUserId));
// Credit to destination
await tx
.update(accounts)
.set({ balance: sql`${accounts.balance} + ${amount}` })
.where(eq(accounts.userId, toUserId));
// Log the transaction
await tx.insert(transactions).values({
fromUserId,
toUserId,
amount,
timestamp: new Date(),
});
return { success: true };
});
}
Drizzle Security Checklist
- All queries use query builder methods or
sqltemplate literals - No
sql.raw()usage with user input - Dynamic column/table names validated against allowlists
- All user input validated with Zod before database operations
- Update operations explicitly list allowed fields (no mass assignment)
- Delete operations include ownership checks
- Sensitive operations wrapped in transactions
- Connection strings stored in environment variables
- Database credentials not logged or exposed in errors
- Prepared statements used for repeated queries
Connection Security
// db.ts - Secure connection setup
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
// Never log or expose the connection string
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL not configured');
}
// For serverless (connection pooling)
const client = postgres(connectionString, {
ssl: 'require', // Always use SSL in production
max: 1, // Single connection for serverless
});
export const db = drizzle(client);
// For migrations (direct connection)
const migrationClient = postgres(process.env.DIRECT_DATABASE_URL!, {
ssl: 'require',
max: 1,
});
export const migrationDb = drizzle(migrationClient);
Error Handling
Don't expose database errors to users:
export async function createUser(data: CreateUserInput) {
try {
const user = await db.insert(users).values(data).returning();
return { success: true, user };
} catch (error) {
// Log the full error internally
console.error('Database error:', error);
// Return generic error to client
if (error instanceof Error) {
// Check for unique constraint violations
if (error.message.includes('unique constraint')) {
return { success: false, error: 'Email already exists' };
}
}
// Generic error for everything else
return { success: false, error: 'Failed to create user' };
}
}
Is Drizzle's query builder always safe from SQL injection?
Yes, when using the query builder methods (select, insert, update, delete) and the sql template literal, values are automatically parameterized. The only danger is using sql.raw() with user input or building query strings manually.
::
When should I use sql.raw()?
Almost never with user input. Use sql.raw() only for static SQL fragments like table names or column names that you control, not user-provided values. For dynamic sorting/filtering, use allowlists to validate user input first.
Do I need to escape LIKE patterns?
Drizzle parameterizes the value, but special LIKE characters (%, ) in user input will still be interpreted. If you want to search for literal % or , escape them: searchTerm.replace(/%/g, '\%').replace(//g, '\') .
How do I prevent users from accessing other users' data?
Always include ownership checks in your queries. For every query that accesses user-specific data, include a WHERE clause that checks the userId: .where(and(eq(posts.id, postId), eq(posts.userId, currentUserId)))
::
What CheckYourVibe Detects
When scanning your Drizzle project, CheckYourVibe identifies:
- Usage of
sql.raw()with user input - String concatenation in SQL queries
- Missing input validation before database operations
- Mass assignment vulnerabilities in update operations
- Missing ownership checks in queries
- Hardcoded database credentials
Run npx checkyourvibe scan to catch these issues before they reach production.