Published on

SQL Injection in 2026 — How It Still Happens With ORMs and How to Prevent It

Authors

Introduction

SQL injection should be solved—we have ORMs, parameterized queries, and prepared statements. Yet in 2025-2026, SQL injection remains in the OWASP Top 10 and regularly compromises production systems. The reason: developers use ORMs' "escape hatches" (raw SQL) without parameterization, assume frameworks handle escaping (they don't always), and concatenate user input into stored procedures.

This post covers how SQL injection still happens with Prisma, Drizzle, and raw database drivers, and the patterns that actually prevent it.

Raw SQL in Prisma: The Injection Vector

Prisma's raw() and $executeRaw() are escape hatches—and dangerous ones:

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

const prisma = new PrismaClient();

// VULNERABLE: String concatenation
const queryVulnerable = async (userId: string) => {
  const result = await prisma.$queryRaw`
    SELECT * FROM users WHERE id = '${userId}'
  `;
  return result;
};

// Attack: userId = "' OR '1'='1"
// Results in: SELECT * FROM users WHERE id = '' OR '1'='1'
// Returns all users

// VULNERABLE: Using string concatenation with raw()
const updateVulnerable = async (userId: string, name: string) => {
  await prisma.$executeRaw(
    `UPDATE users SET name = '${name}' WHERE id = '${userId}'`
  );
};

// Attack: name = "admin', role = 'admin"
// Results in: UPDATE users SET name = 'admin', role = 'admin' WHERE id = '...'

// SECURE: Parameterized query with template literals
const querySecure = async (userId: string) => {
  const result = await prisma.$queryRaw`
    SELECT * FROM users WHERE id = ${userId}
  `;
  // Prisma interpolates parameters safely
  return result;
};

// SECURE: Using Prisma's query builder (prevents injection)
const queryWithBuilder = async (userId: string) => {
  const result = await prisma.users.findUnique({
    where: { id: userId },
  });
  return result;
};

// SECURE: If raw() required, use arrays for parameterization
const executeSecureArray = async (userId: string, name: string) => {
  await prisma.$executeRaw`
    UPDATE users SET name = ${name} WHERE id = ${userId}
  `;
};

Drizzle ORM Protection

Drizzle's sql template literals provide parameterization:

import { drizzle } from 'drizzle-orm/node-postgres';
import { sql } from 'drizzle-orm';
import { users } from './schema';

const db = drizzle();

// VULNERABLE: Template string without sql helper
const queryVulnerable = async (userId: string) => {
  // This might be tempting but is dangerous
  const query = `SELECT * FROM users WHERE id = '${userId}'`;
  // Don't do this!
};

// SECURE: Use sql template literal
const querySecure = async (userId: string) => {
  const result = await db.execute(
    sql`SELECT * FROM users WHERE id = ${userId}`
  );
  return result;
};

// SECURE: Drizzle query builder (safest)
const findUser = async (userId: string) => {
  const result = await db.select().from(users).where(eq(users.id, userId));
  return result;
};

// SECURE: Complex query with parameterization
const complexQuery = async (userId: string, minBalance: number) => {
  const result = await db.execute(sql`
    SELECT u.id, u.name, a.balance
    FROM users u
    JOIN accounts a ON u.id = a.user_id
    WHERE u.id = ${userId}
      AND a.balance > ${minBalance}
  `);
  return result;
};

// SECURE: Dynamic WHERE clause with parameterization
const buildDynamicQuery = async (filters: Record<string, any>) => {
  let whereClause = sql`TRUE`;

  if (filters.userId) {
    whereClause = sql`${whereClause} AND id = ${filters.userId}`;
  }

  if (filters.status) {
    whereClause = sql`${whereClause} AND status = ${filters.status}`;
  }

  const result = await db.execute(
    sql`SELECT * FROM users WHERE ${whereClause}`
  );
  return result;
};

Stored Procedure Injection

Stored procedures are not immune:

// VULNERABLE: Stored procedure with string concatenation
const callStoredProcedureVulnerable = async (
  userId: string
) => {
  const result = await db.execute(
    sql`CALL validate_user('${userId}')`
  );
  return result;
};

// Attack: userId = "abc'); DELETE FROM users; --"
// Executes: CALL validate_user('abc'); DELETE FROM users; --')

// SECURE: Parameterized stored procedure call
const callStoredProcedureSecure = async (
  userId: string
) => {
  const result = await db.execute(sql`CALL validate_user(${userId})`);
  return result;
};

// PostgreSQL example with OUT parameters
interface ValidateUserResult {
  isValid: boolean;
  role: string;
}

const validateUserSecure = async (
  userId: string
): Promise<ValidateUserResult> => {
  const result = await db.execute(sql`
    SELECT is_valid, role
    FROM validate_user(${userId})
  `);

  return {
    isValid: result[0].is_valid,
    role: result[0].role,
  };
};

// SECURE: MySQL stored procedure with parameter
const callMySQLProcedure = async (
  email: string
): Promise<any> => {
  const result = await db.execute(sql`
    CALL create_user_account(${email}, @result)
  `);

  // Retrieve OUT parameter
  const selectResult = await db.execute(sql`SELECT @result as result`);
  return selectResult[0].result;
};

Second-Order SQL Injection

User input stored, then executed later:

// VULNERABLE: Input stored, later used in query
const storeQuerySecond = async (userId: string, filter: string) => {
  // First: Store user input
  await db.execute(sql`
    UPDATE user_filters SET query = ${filter} WHERE user_id = ${userId}
  `);
};

const executeStoredQuery = async (userId: string) => {
  // Second: Retrieve and execute (VULNERABLE!)
  const row = await db.execute(sql`
    SELECT query FROM user_filters WHERE user_id = ${userId}
  `);

  const storedQuery = row[0].query;

  // VULNERABLE: Executing stored filter as SQL
  const result = await db.execute(storedQuery); // Injection here!
  return result;
};

// SECURE: Don't execute stored input as SQL
// Instead: Use stored input as data parameter
const executeStoredQuerySecure = async (userId: string) => {
  const row = await db.execute(sql`
    SELECT query FROM user_filters WHERE user_id = ${userId}
  `);

  const storedQuery = row[0].query;

  // Parse the filter as data, not SQL
  const filterObj = JSON.parse(storedQuery);

  // Build SQL with parsed filter as parameters
  let whereClause = sql`TRUE`;

  if (filterObj.status) {
    whereClause = sql`${whereClause} AND status = ${filterObj.status}`;
  }

  const result = await db.execute(sql`
    SELECT * FROM data WHERE ${whereClause}
  `);

  return result;
};

// SECURE: Store filter structure, apply as parameterized conditions
interface StoredFilter {
  field: string;
  operator: 'eq' | 'gt' | 'lt' | 'contains';
  value: string;
}

const applyStoredFilters = async (
  userId: string,
  filters: StoredFilter[]
): Promise<any> => {
  let query = sql`SELECT * FROM data WHERE TRUE`;

  for (const filter of filters) {
    // Whitelist allowed fields
    const allowedFields = ['status', 'name', 'email', 'created_at'];

    if (!allowedFields.includes(filter.field)) {
      throw new Error(`Invalid filter field: ${filter.field}`);
    }

    // Build safe parameterized condition
    const field = sql.identifier([filter.field]);

    switch (filter.operator) {
      case 'eq':
        query = sql`${query} AND ${field} = ${filter.value}`;
        break;
      case 'contains':
        query = sql`${query} AND ${field} LIKE ${'%' + filter.value + '%'}`;
        break;
      case 'gt':
        query = sql`${query} AND ${field} > ${filter.value}`;
        break;
    }
  }

  return await db.execute(query);
};

NoSQL Injection (MongoDB)

NoSQL is not immune:

import { MongoClient } from 'mongodb';

const client = new MongoClient('mongodb://localhost:27017');
const db = client.db('testdb');
const users = db.collection('users');

// VULNERABLE: $where with user input
const queryVulnerable = async (userId: string) => {
  const result = await users.find({
    $where: `this._id == "${userId}"`, // VULNERABLE!
  });
  return result;
};

// Attack: userId = '"; return true; //'
// Results in: this._id == ""; return true; //"
// Returns all documents

// VULNERABLE: Regex injection
const searchVulnerable = async (query: string) => {
  const result = await users.find({
    name: { $regex: query }, // User controls regex
  });
  return result;
};

// Attack: query = ".*" matches everything

// SECURE: Direct query with parameter
const querySecure = async (userId: string) => {
  const result = await users.find({
    _id: userId, // Direct field match, no code injection
  });
  return result;
};

// SECURE: Regex with escaping
const searchSecure = async (query: string) => {
  // Escape special regex characters
  const escapedQuery = query.replace(
    /[.*+?^${}()|[\]\\]/g,
    '\\$&'
  );

  const result = await users.find({
    name: { $regex: escapedQuery, $options: 'i' },
  });
  return result;
};

// SECURE: Always use field-based queries
const updateSecure = async (userId: string, newName: string) => {
  const result = await users.updateOne(
    { _id: userId },
    { $set: { name: newName } }
  );
  return result;
};

Input Validation as Defense-in-Depth

Validation doesn't replace parameterization but helps:

interface UserInput {
  userId: string;
  limit: number;
}

class InputValidator {
  validateUserId(userId: string): string {
    // Whitelist format: alphanumeric + hyphens only
    if (!/^[a-zA-Z0-9-]{1,36}$/.test(userId)) {
      throw new Error('Invalid user ID format');
    }
    return userId;
  }

  validateLimit(limit: any): number {
    const num = parseInt(limit, 10);
    if (isNaN(num) || num < 1 || num > 1000) {
      throw new Error('Invalid limit');
    }
    return num;
  }

  validateEmail(email: string): string {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(email)) {
      throw new Error('Invalid email');
    }
    return email;
  }
}

const validator = new InputValidator();

app.get('/api/users/:userId', async (req: express.Request, res: express.Response) => {
  try {
    const userId = validator.validateUserId(req.params.userId);

    const user = await db.execute(sql`
      SELECT * FROM users WHERE id = ${userId}
    `);

    res.json(user);
  } catch (error) {
    res.status(400).json({ error: (error as Error).message });
  }
});

WAF Rules for SQL Injection

Web Application Firewall patterns:

# ModSecurity rules for SQL injection
SecRule ARGS "@detectSQLi" "id:1001,phase:2,deny,status:403"

# Common SQL keywords
SecRule ARGS "(?i)(union|select|insert|update|delete|drop|create|alter|exec|execute|script)" \
  "id:1002,phase:2,log,deny"

# Comment patterns
SecRule ARGS "(?i)(--|#|\/\*|;)" "id:1003,phase:2,log,deny"

# Quoted strings
SecRule ARGS "(['\"]).*(['\"])" "id:1004,phase:2,log,deny"

# UNION-based injection
SecRule ARGS "(?i)union.*select" "id:1005,phase:2,deny"

Safe Pattern with Tagged Templates

// TypeScript tagged template for SQL
const sql = (strings: TemplateStringsArray, ...values: any[]) => {
  let query = strings[0];
  const params = [];

  for (let i = 0; i < values.length; i++) {
    params.push(values[i]);
    query += `$${params.length}` + strings[i + 1];
  }

  return { query, params };
};

// Usage
const query = sql`SELECT * FROM users WHERE id = ${userId} AND email = ${email}`;

// query = {
//   query: 'SELECT * FROM users WHERE id = $1 AND email = $2',
//   params: [userId, email]
// }

// Execute with parameters
await db.query(query.query, query.params);

Checklist

  • Never concatenate user input into SQL
  • Use parameterized queries (${...} in template literals)
  • Avoid raw() and $executeRaw() unless absolutely necessary
  • Never execute user input as stored SQL code
  • Whitelist allowed fields in dynamic queries
  • Use ORM query builders instead of raw SQL
  • Validate and sanitize input format (email, ID, etc.)
  • Test with SQL injection payloads in CI
  • Monitor query logs for suspicious patterns
  • Deploy WAF rules to catch injection attempts

Conclusion

SQL injection in 2026 persists because developers use raw SQL, trust ORM defaults (which have escape hatches), and misunderstand parameterization. The fix is simple: parameterized queries everywhere, ORM query builders as default, and raw SQL only with template literals. Combine this with input validation and WAF rules, and SQL injection becomes nearly impossible to exploit.