Back to Blog
Security9 min

Your ORM Won't Save You From SQL Injection

ORMs parameterize queries, so SQL injection is solved, right? Wrong. Every major ORM has escape hatches that bypass protection, and developers use them constantly.

By Security TeamMarch 2, 2026

"We use an ORM, so SQL injection isn't a concern." If I had a dollar for every time a dev said this during a security review, I could retire. ORMs do parameterize queries by default. They do abstract away raw SQL. And they definitely reduce injection risk compared to string concatenation everywhere.

But "reduced risk" isn't "eliminated risk." Every major ORM ships with raw SQL escape hatches. And teams use them constantly — for performance, for complex queries, for that one thing the query builder can't express. When you use those escape hatches wrong, the ORM's protection disappears.

ORMs Are Productivity Tools, Not Security Boundaries

ORMs weren't designed to be your security layer. They're productivity tools. The parameterization that happens under the hood is a side effect of how they build queries, not some deliberate security boundary you can lean on.

Teams operating under "the ORM handles it" stop looking for injection bugs during code review. Why would you? The ORM takes care of it, right? Meanwhile, raw SQL queries accumulate in utils files, background jobs, admin panels. Nobody tracks them. Nobody audits them. And then SQLMap finds them in 30 seconds during a pentest.

Sequelize: literal() Breaks Everything

Sequelize is one of the most popular Node.js ORMs. Almost every non-trivial Sequelize project eventually uses Sequelize.literal(). This function injects raw SQL directly into queries, completely bypassing parameterization.

// VULNERABLE — user controls the ORDER BY clause
const results = await Order.findAll({
  where: { status: 'completed' },
  order: Sequelize.literal(req.query.sortField + ' ' + req.query.sortDir)
});

// Attacker sends: ?sortField=1;DROP TABLE orders--&sortDir=ASC
// Resulting SQL: ORDER BY 1;DROP TABLE orders-- ASC

Classic injection hiding behind an ORM that's supposed to prevent exactly this.

The fix isn't complicated:

// SAFE — whitelist allowed columns
const ALLOWED_SORT = ['created_at', 'total', 'customer_name'];
const ALLOWED_DIR = ['ASC', 'DESC'];

const sortField = ALLOWED_SORT.includes(req.query.sortField)
  ? req.query.sortField
  : 'created_at';
const sortDir = ALLOWED_DIR.includes(req.query.sortDir?.toUpperCase())
  ? req.query.sortDir.toUpperCase()
  : 'ASC';

const results = await Order.findAll({
  where: { status: 'completed' },
  order: [[sortField, sortDir]]
});

Whitelisting. Not sanitization, not escaping. For identifiers like column names, whitelisting is the only reliable defense.

Prisma: Even $queryRawUnsafe Has "Unsafe" In The Name

Prisma markets itself as next-gen with strong type safety. And to be fair, the standard Prisma Client API is genuinely hard to inject through. But then developers need dynamic queries and reach for $queryRaw or $executeRaw.

// SAFE — tagged template handles parameterization automatically
const users = await prisma.$queryRaw`
  SELECT * FROM users WHERE email = ${userEmail}
`;

// VULNERABLE — string concatenation bypasses everything
const users = await prisma.$queryRawUnsafe(
  'SELECT * FROM users WHERE name = \'' + userName + '\''
);

The method is literally called $queryRawUnsafe. The name tells you it's dangerous. Yet it shows up in production all the time, usually because someone copied a Stack Overflow snippet or a deadline was tight and the tagged template syntax felt awkward.

Even the "safe" tagged template version has a gotcha. Dynamic table or column names can't be parameterized through SQL parameters — they need to be interpolated as raw SQL:

// STILL VULNERABLE — attacker controls the table name
const table = req.query.resource;
const results = await prisma.$queryRawUnsafe(
  `SELECT * FROM ${table} WHERE active = true`
);

// SAFE — validate table name against known values
const ALLOWED_TABLES = ['users', 'products', 'orders'];
if (!ALLOWED_TABLES.includes(req.query.resource)) {
  throw new Error('Invalid resource');
}
const results = await prisma.$queryRaw(
  Prisma.sql`SELECT * FROM ${Prisma.raw(req.query.resource)} WHERE active = true`
);

Django ORM: extra() and RawSQL

Django's ORM is one of the most mature out there. The docs explicitly warn about injection risks. And still, the escape hatches get misused constantly.

# VULNERABLE — f-string injection
User.objects.extra(
    where=[f"username = '{request.GET['user']}'"]
)

# VULNERABLE — RawSQL with string formatting
from django.db.models.expressions import RawSQL
queryset = queryset.annotate(
    val=RawSQL(f"select col from sometable where othercol = '{param}'", [])
)

# SAFE — use the params argument
User.objects.extra(
    where=["username = %s"],
    params=[request.GET['user']]
)

# SAFE — pass parameters properly
queryset = queryset.annotate(
    val=RawSQL("select col from sometable where othercol = %s", [param])
)

Django deprecated extra() years ago, but legacy codebases are full of it. The pattern is always the same: developer needed something the ORM couldn't express, reached for raw SQL, forgot to parameterize.

ActiveRecord: String Interpolation Looks Too Normal

Ruby on Rails ActiveRecord has a sneaky pitfall because vulnerable and safe patterns look nearly identical:

# VULNERABLE — Ruby string interpolation
User.where("email = '#{params[:email]}'")

# SAFE — parameterized
User.where("email = ?", params[:email])

# ALSO SAFE — hash syntax
User.where(email: params[:email])

Three lines. Two safe, one completely broken. In a code review, the vulnerable version doesn't jump out — it looks like normal Ruby. A few characters difference between "secure" and "compromised."

Where Injection Keeps Showing Up

After auditing enough codebases, clear patterns emerge. Injection points aren't random — they cluster around specific features:

Dynamic Sorting and Filtering

Admin panels, data tables, API endpoints with flexible sorting. Developers need dynamic ORDER BY or WHERE clauses. The ORM's standard API doesn't handle dynamic column references well. Raw SQL sneaks in.

Search Features

Full-text search, LIKE queries with wildcards, search across multiple columns. Dynamic search queries built by concatenating conditions are injection magnets.

Reporting and Analytics

Complex aggregations, GROUP BY with dynamic dimensions, pivot queries. These push beyond what most ORMs handle elegantly, forcing developers toward raw queries.

Database-Specific Features

JSON operators in PostgreSQL, full-text indexes in MySQL, window functions. ORM support for database-specific syntax is often incomplete or awkward.

What Actually Works

Telling developers "don't use raw SQL" is like telling them "don't write bugs." It's not realistic. Raw SQL exists because sometimes you need it. Make it safe instead of pretending it won't happen.

1. Grep Your Codebase Now

Run these searches and review every single result:

# Sequelize
grep -rn "Sequelize.literal\|\.query(" --include="*.ts" --include="*.js" src/

# Prisma
grep -rn "queryRawUnsafe\|executeRawUnsafe" --include="*.ts" src/

# Django
grep -rn "\.extra(\|RawSQL\|\.raw(" --include="*.py" .

# ActiveRecord
grep -rn '\.where("' --include="*.rb" app/

Every hit is a potential injection point. Not all will be vulnerable, but each one needs eyes on it.

2. Build a Safe Query Wrapper

If raw SQL is unavoidable, centralize it behind a wrapper that forces validation:

class SafeQuery {
  private static readonly IDENTIFIER_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;

  static validateIdentifier(name: string): string {
    if (!this.IDENTIFIER_REGEX.test(name)) {
      throw new Error(`Invalid identifier: ${name}`);
    }
    return name;
  }

  static validateEnum(
    value: string,
    allowed: readonly T[]
  ): T {
    if (!allowed.includes(value as T)) {
      throw new Error(`Invalid value. Allowed: ${allowed.join(', ')}`);
    }
    return value as T;
  }
}

// Usage
const sortField = SafeQuery.validateEnum(
  req.query.sort,
  ['created_at', 'total', 'status'] as const
);

3. Lint Rules For Raw SQL

Add ESLint rules that flag raw SQL methods. Not to block them — to force a review. When Sequelize.literal() triggers a lint warning, it shows up in PRs and someone has to look at it.

4. Test With Malicious Input

Add integration tests that send classic injection payloads through every database-touching endpoint:

describe('SQL injection protection', () => {
  const payloads = [
    "'; DROP TABLE orders; --",
    "1 OR 1=1",
    "' UNION SELECT password FROM users --",
  ];

  payloads.forEach((payload) => {
    it(`handles: ${payload}`, async () => {
      const res = await request(app)
        .get('/api/orders')
        .query({ sort: payload });

      expect(res.status).not.toBe(500);
      expect([200, 400]).toContain(res.status);
    });
  });
});

Why Teams Keep Missing This

SQL injection through ORMs isn't a technology problem. It's a knowledge gap. Junior devs learn "ORMs prevent injection." Senior devs know "ORMs reduce injection risk." That distinction sounds academic until a security audit finds Sequelize.literal() calls with user input flowing through them.

Code reviews catch some issues. But reviewers scan for obvious problems — they're less likely to trace data flow from a request parameter through multiple function calls into a raw SQL fragment buried in a utility file. Automated tools don't get tired and don't skip files.

Automated Scanning Finds What Humans Miss

Manual code review matters, but it has limits. A reviewer checking 200 lines might miss the one $queryRawUnsafe call in a helper function that wasn't part of the PR diff. Static analysis doesn't miss files.

ScanMyCode.dev scans entire codebases for injection vulnerabilities — not just obvious string concatenation patterns, but ORM-specific escape hatches that are easy to overlook. The security audit covers all OWASP Top 10 vulnerabilities, with exact file/line numbers and remediation advice. 24-hour turnaround.

Stop Assuming ORMs Handle Everything

ORMs are excellent tools. Keep using them. But drop the assumption that they automatically handle injection. Audit every raw SQL escape hatch in your codebase. Whitelist identifiers. Add injection-focused tests. And consider an automated scan — a security audit costs less than one hour of incident response.

ORMs handle 95% of the problem. The other 5% is what ends up in breach reports.

SQL injectionORM securitySequelizePrismadatabase security

Ready to improve your code?

Get an AI-powered code audit with actionable recommendations. Results in 24 hours.

Start Your Audit