Security

SQL Injection in ORM; Where It Doesn't Get Parameterized

ORM doesn't prevent SQL injection by itself. See the three spots where the query builder constructs strings and how to close them off.

Security

You look at the logged query and see that what was sent differs from what you wrote in the code. An OR 1=1 is sitting at the end of the condition, or an ORDER BY whose column you didn't specify. The code uses Eloquent or Hibernate or Prisma, so it should be safe. It isn't. SQL injection in ORM isn't a contradiction; it's the direct result of the fact that ORM only parameterizes where it itself builds the query string. Anywhere you build a string and hand it over, the protection goes silent.

There are three specific spots where this happens. All three are in code that gets written every day, and all of them look healthy in code review.

whereRaw and its siblings

Every ORM has an escape hatch. In Laravel it's called whereRaw, in TypeORM it's query(), in SQLAlchemy it's text(). These are for when the query builder can't handle something and you have to write raw SQL. The problem is that this hatch has no protection at all.

// This is injectable
$users = DB::table('users')
    ->whereRaw("email = '$email'")
    ->get();

// This isn't
$users = DB::table('users')
    ->whereRaw('email = ?', [$email])
    ->get();

The difference is one question mark. The first version builds the string before it reaches the driver, so a quote inside $email breaks the query structure. The second version sends the value as a bind parameter, and the driver sees it as data, not code.

Here's where people go wrong: many think that if they sanitize the input with htmlspecialchars or a manual escape function, that's enough. You see the sign of this mistake in the log, not in the test. The query works fine in the development environment because your test input has no quotes. In production, the first request that contains a ' either throws a syntax error or, worse, silently returns the wrong result. Manual escaping is also almost always incomplete; every database has its own quoting rules, and the driver knows those rules more precisely than you do.

Column name and sort direction from user input

This is the most serious case, because bind parameters simply don't work here. A placeholder is only for values. The column name, table name, and the ASC/DESC keyword are part of the query structure, and the database doesn't allow them to be parameterized.

// ?sort=created_at&dir=desc
$sort = $request->query('sort');
$dir  = $request->query('dir');

User::orderBy($sort, $dir)->paginate();

If $sort goes straight into orderBy, an attacker can sort by any column they want and extract the value of columns like password_hash or api_token from the ordering of results. This technique isn't blind; with a few hundred sorted requests, the value leaks out bit by bit. If your ORM doesn't run orderBy with internal validation, you can even slip a full subquery in place of the column name.

The right way is an allow-list. Not filtering out dangerous characters, not a regex on the column pattern.

$allowed = ['created_at', 'name', 'price'];
$sort = in_array($request->query('sort'), $allowed, true)
    ? $request->query('sort')
    : 'created_at';

$dir = $request->query('dir') === 'asc' ? 'asc' : 'desc';

Any input that goes into the query structure must be chosen from a closed set, not "sanitized." The difference between the two is that an allow-list is inherently safe and filtering is inherently fragile.

IN and LIKE and dynamic sorting

Two other spots that are often taken lightly. First, whereIn with an array built from input: if you convert the array to a string with implode and put it inside whereRaw, you're back to the same first problem. whereIn('id', $ids) parameterizes by itself; whereRaw("id IN ($ids)") doesn't.

Second, LIKE. Even when you bind the value, the % and _ characters in user input have wildcard meaning. This isn't SQL injection, but it's a lightweight DoS attack: a % at the start of the pattern renders the index useless and, on a table with a few million rows, takes the query from a few milliseconds to a few seconds. If your site's search has gotten slow and database CPU is high, check the LIKE patterns before anything else.

Don't bypass the ORM, but don't trust it either

My position is clear: as long as the query builder works, use it and don't go to raw SQL. If you're forced to, only with placeholders. If placeholders don't work (like for column names), allow-list. Don't change this order.

But this decision has a cost, and you should know it. An allow-list means that every time you add a sortable column, a code change is required. Teams that see this as "unmaintainable" go for filtering and six months later are facing a data breach report. Pay the maintenance cost of the allow-list; it's cheaper than rewriting the users table.

There's also an operational point: ORM logs errors with the full query. If APP_DEBUG is on in production, that same injectable query comes back in the HTTP response along with the bind values and gives the attacker a roadmap. We've seen this in WordPress security; from wp-config to plugins that are holes themselves too: most leaks start with a forgotten debug setting, not a complex vulnerability.

How to find out if your code is actually vulnerable

Three things, in this order.

  1. Search the entire codebase for whereRaw, orderByRaw, selectRaw, DB::raw, query(), and text(). Read every hit manually. This grep takes ten minutes and usually turns up two to five cases.
  2. Trace every parameter that goes into orderBy, groupBy, select, or a table name to see whether it comes from the request.
  3. On staging, test those same endpoints with a scanning tool like sqlmap. If you feed it the sort parameter with --level=5 --risk=3, you usually get a result within a few minutes.

If you have audit logs, you can trace back requests with unusual sort= or long-running queries. If you don't, What are audit logs and how do we keep them intact? is the right starting point, because without logs you won't know whether an attack has started or not.

And one defensive layer that many forget: the application's database user shouldn't have DROP or FILE. If an injection succeeds but the user only has SELECT and INSERT on a few tables, the scope of damage shrinks from "the whole server" to "one table." This five-minute setting makes the worst-case scenario tolerable.

If you'd prefer to hand this review off to another team, ServerNet's security services include checking these same patterns in code and server configuration. To see the live status of services, check Service Status.

Frequently asked questions

Does using an ORM by itself prevent SQL injection?

No. An ORM only protects when it builds the query string itself and sends the value as a bind parameter. As soon as you build a string with whereRaw or DB::raw and put user input inside it, the same raw SQL vulnerability comes back.

Simple rule: anywhere in your code a string is concatenated with . or + to become a query, that spot is suspect.

Why can't I parameterize a column name with a placeholder?

Because a placeholder is for a value, not an identifier. The database parses the query structure before execution, and the column name must be determined at that stage; the bind parameter is filled in after that stage.

So for column names, tables, and sort direction, you have no choice but an allow-list. Build a fixed array of allowed values and return anything else to the default.

What are the signs of a SQL injection attack in logs?

Queries with extra single quotes, OR 1=1, UNION SELECT, -- or /* comments at the end of a condition, and parameters with nested URL encoding. At the server level, back-to-back requests with different sort parameters on one endpoint are the classic blind-extraction pattern.

If you have separate, intact audit logs, you can figure out which queries actually ran and limit the scope of the leak.

Does a WAF stop this type of injection?

Part of it, but not all. A WAF works on known patterns in input parameters and usually doesn't see injection via a sort column name that comes in as a simple word. DDoS protection and a WAF are load-reduction layers, not a replacement for an allow-list in code.

The right order is: fix the code first, then add the network layer. The reverse only buys time.

ServerNet Support

ServerNet engineering & editorial team — specialists in infrastructure, networking and web hosting.

Security Services
Share:

Comments 0

No comments yet — be the first!

Leave a comment

Related service

Security Services

Penetration testing by OSCP-certified specialists, infrastructure hardening and 24/7 security monitoring — reports managers understand and engineers can act on.