Drizzle ORM Security Guide for Vibe Coders

Share

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 sql template 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.

Tool & Platform Guides

Drizzle ORM Security Guide for Vibe Coders