Web
SQL injection in the ORM era: where the vulnerability hides
An ORM does not fully protect you from SQL injection. The risky spots in whereRaw, orderBy, LIKE and raw queries, how to test them, and how to fix them in Laravel and Node.
· 10 min read

SQL injection is one of the oldest and best-understood vulnerabilities. Many developers consider it a thing of the past: "we use Eloquent", "Prisma escapes everything for us", "we have no hand-written SQL". And it is true that modern ORMs send ordinary queries in parameterised form and prevent injection in the simple cases.
In practice, though, the vulnerability did not disappear — it moved. Now it hides in a single line inside whereRaw, in a sort column the user picks, in a raw query written in a hurry for a report, or in a LIKE search. In this article we look at where SQL injection shows up in the ORM era, how to check for it yourself, and how to close it.
The core of the problem
SQL injection happens when user input is interpreted as code of the SQL query. When data and command are glued into one string, a quote or keyword inside the data changes the structure of the query.
The classic example:
$sql = "SELECT * FROM users WHERE email = '" . $request->email . "'";
DB::select($sql);
If ' OR '1'='1 is sent in the email field, the condition is always true. With more elaborate queries an attacker can read data from other tables, and sometimes write it.
The core defence has not changed: data and query code must travel separately. Prepared statements (parameterised queries) provide this — the database receives the query structure first, and the values arrive later, separately, and are never executed as code.
ORMs do exactly that for you. The trouble starts where you step outside the ORM.
Why it matters for the expertise
SQL injection belongs to category A03 "Injection" of the OWASP Top 10 (2021) and is classified as CWE-89. When a web application is examined in the official cybersecurity expertise, injection tests are almost always part of it: search fields, filters, sort parameters, API endpoints and forms are probed with automated tools and by hand.
Such a finding is usually rated high risk, because it can open access to everything in the database — users, password hashes, personal and financial data. Finding and closing these spots yourself before the expertise is far cheaper.
Where the vulnerability hides in the ORM era
1. Raw queries and "Raw" methods
In Laravel, DB::select, DB::statement, whereRaw, selectRaw, orderByRaw, havingRaw and DB::raw all accept SQL directly. They are not dangerous as long as values are passed as bindings. The danger is in string concatenation or interpolation:
// Dangerous
User::whereRaw("YEAR(created_at) = $year")->get();
// Safe
User::whereRaw('YEAR(created_at) = ?', [$year])->get();
Node is no different. sequelize.query() in Sequelize, knex.raw() in Knex, and $queryRawUnsafe / $executeRawUnsafe in Prisma are doors that open the risky way:
// Dangerous
await prisma.$queryRawUnsafe(`SELECT * FROM orders WHERE status = '${status}'`);
// Safe: the tagged template turns values into parameters
await prisma.$queryRaw`SELECT * FROM orders WHERE status = ${status}`;
Prisma's $queryRaw parameterises values when called as a tagged template; $queryRawUnsafe does not — the name says so.
2. Sort column and direction (orderBy)
This is one of the most common injection spots in the ORM era. On a table page the user sorts "by date" or "by amount", and the frontend sends ?sort=created_at&dir=desc. The developer passes it straight through:
$query->orderBy($request->sort, $request->dir);
The problem is that column names and the ASC/DESC keywords cannot be parameterised with prepared statements — SQL itself does not allow it. The framework quotes the identifier, but relying on that alone is wrong, especially with orderByRaw or JSON columns. The right solution is an allowlist:
$allowed = ['created_at', 'amount', 'status'];
$sort = in_array($request->sort, $allowed, true) ? $request->sort : 'created_at';
$dir = $request->dir === 'asc' ? 'asc' : 'desc';
$query->orderBy($sort, $dir);
The same rule applies to table names, column lists (select), groupBy and dynamic filter fields: an identifier coming from the user must be chosen only from a predefined list.
3. LIKE and search
LIKE searches have two problems. The first is classic injection when the query is built by hand:
// Dangerous
DB::select("SELECT * FROM products WHERE name LIKE '%$q%'");
The second is wildcard injection. Even in a parameterised query, % and _ have special meaning. If a user sends %, every record comes back; long patterns like %_%_%_ can put a heavy load on the database. This is not SQL injection, but it can lead to data exposure or a slowed-down service. Escape the characters:
$term = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $request->q);
Product::where('name', 'like', "%{$term}%")->get();
In PostgreSQL the same applies to ILIKE.
4. Arrays and IN
whereIn is safe, but developers sometimes turn a list into a string and append it to a raw query:
// Dangerous
DB::select("SELECT * FROM users WHERE id IN (" . implode(',', $request->ids) . ")");
// Safe
User::whereIn('id', array_map('intval', $request->ids))->get();
5. JSON queries and operators
In NoSQL databases (MongoDB) injection looks different: if an object from the user reaches the query, operators such as {"$ne": null} bypass the condition. In Express, req.body.password may be an object rather than a string. Check the type of incoming data (for example with zod or Joi) and filter out operators.
6. Stored procedures and second-order injection
Data may be written to the database safely, but if it is later inserted into raw SQL somewhere else — in a report generator or inside a stored procedure — the injection happens there. This is called second-order injection. The rule is simple: data read from the database is not trusted either; pass it as a parameter.
The ORM protects you, but only while you stay inside it. Every "Raw" call and every dynamic identifier is a separate spot that needs checking.
How to check it yourself
Searching the code
The first step is to find risky patterns in the codebase. For a Laravel project:
grep -rnE "whereRaw|selectRaw|orderByRaw|havingRaw|groupByRaw|DB::raw|DB::select|DB::statement|DB::unprepared" app/ routes/
grep -rnE "orderBy\(\\\$request|orderBy\(request\(" app/
For a Node project:
grep -rnE "queryRawUnsafe|executeRawUnsafe|sequelize\.query|knex\.raw|\.query\(\`" src/
At each hit, ask: does a user-supplied value end up in the query? If so — is there a binding or an allowlist?
Manual testing
In your own test environment, send simple characters in the parameters and watch how the application behaves:
'(a single quote) — a 500 error or SQL error text is a strong signal.- Compare the responses for
?sort=created_atand?sort=nonexistent. - If values such as
?sort=(SELECT 1)or?dir=asc,idreturn an error, the identifier is not being validated. - Send
%and_to the search — do all records come back?
In production APP_DEBUG must be false, otherwise SQL error messages are visible to users and that helps an attacker a lot. More on this: debug mode and information disclosure.
sqlmap — carefully
sqlmap is an open-source tool that detects SQL injection automatically. It is powerful, but precautions matter:
- Use it only on systems you own or have written permission to test. Scanning someone else's system without permission is against the law.
- Run it against staging or a test copy, not production. sqlmap sends many requests and some techniques can modify data.
- Start at a low level:
sqlmap -u "https://staging.example.uz/products?sort=name&q=test" \
--batch --level=1 --risk=1 -p sort,q
Raising --risk enables UPDATE- and OR-based tests, which can damage data. For authenticated pages, pass a test account's session with --cookie. Confirm every finding by hand.
How to fix it
Laravel
- Use Eloquent and Query Builder methods wherever possible:
where,whereIn,whereBetween,whereDate. - In every
*Rawcall, pass values with?or named bindings. - Use an allowlist for dynamic columns and directions.
- Validate input with a Form Request:
public function rules(): array
{
return [
'sort' => ['nullable', Rule::in(['created_at', 'amount', 'status'])],
'dir' => ['nullable', Rule::in(['asc', 'desc'])],
'q' => ['nullable', 'string', 'max:100'],
'ids' => ['array', 'max:100'],
'ids.*'=> ['integer'],
];
}
- Never use
DB::unpreparedwith user data.
Node (Express)
const allowedSort = new Set(['created_at', 'amount', 'status']);
const sort = allowedSort.has(req.query.sort) ? req.query.sort : 'created_at';
const dir = req.query.dir === 'asc' ? 'asc' : 'desc';
// Knex: values via bindings, identifiers via allowlist
const rows = await knex('orders')
.where('user_id', req.user.id)
.andWhere('status', req.query.status)
.orderBy(sort, dir);
// Raw query with the pg driver
await pool.query('SELECT * FROM orders WHERE id = $1', [req.params.id]);
At the database level
To reduce the damage even if an injection exists:
- The application should not connect as a superuser (
postgres,root). Grant it rights only to the tables and operations it needs. - Create a separate read-only user for reporting modules that only read.
- The application user should not have
COPY ... PROGRAMin PostgreSQL or theFILEprivilege in MySQL. - The database port must not be exposed to the internet.
Least privilege does not remove the injection, but it significantly limits its consequences.
Hiding errors
In production, SQL error text must not reach the user. In Laravel set APP_DEBUG=false; in Express add a general error handler and write the details only to the log. Hiding the error text does not close the vulnerability, but it makes it much harder to find — the real fix is still bindings and allowlists.
Short checklist
- All
*Raw,DB::select,queryRawUnsafeandknex.rawcalls in the codebase have been reviewed. - Every raw query passes values through bindings; no string concatenation.
- Sort column, direction and dynamic fields are checked against an allowlist.
-
LIKEsearches escape%and_, and length is limited. -
INlists go throughwhereInwith type checks. - Input is validated with a Form Request or a schema.
- The application's database user has minimal privileges.
- SQL error messages are not visible to users in production.
- A careful sqlmap test has been run on staging and results confirmed by hand.
Summary
ORMs did not eliminate SQL injection; they pushed it into less visible places. The risk is no longer spread across the whole codebase but concentrated in a few specific patterns: raw queries, dynamic identifiers, search, and second-order data. That is good news — they are easy to find and close. A grep review of the code, a few allowlists and tighter database privileges are usually a few hours of work.
Want to check the raw queries and filters in your project before the expertise? Request a security scan — the Claude AI agent quickly finds risky patterns in your codebase, and our specialist confirms them and helps close the holes.


