About this course
<p>A lot of developers assume that once they're on an ORM, SQL injection stops being their problem. But it doesn't disappear. It just relocates.</p>
<p>ORMs like Sequelize, Prisma, TypeORM, and Knex parameterize the queries they build for you automatically. That part works.</p>
<p>The problem shows up in the parts of your app where you step outside that safe path: a raw query the ORM's query builder can't express cleanly, a dynamic sort column, or a single helper function reached for without a second thought.</p>
<p>Those are exactly the places <a href="https://hackita.it/articoli/hacker/">attackers go looking first</a>, and they're easy for developers to miss precisely because the rest of the codebase feels protected.</p>
<p>Four of those patterns are the focus here. Each one gets a working example that triggers the bug, a breakdown of why it's exploitable, and a rewritten version that closes it. I also include notes on exactly what moved and why.</p>
<p>You don't need a security background to follow along. You just need enough comfort with Node.js, SQL, and at least one ORM to read the code.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>The examples assume you're comfortable with:</p>
<ul>
<li><p>The basics of Node.js and Express</p>
</li>
<li><p>Writing basic SQL queries</p>
</li>
<li><p>Basic use of an ORM (all code examples use Sequelize, but the patterns apply to Prisma, TypeORM, Knex, and others too)</p>
</li>
<li><p>How an HTTP request/response cycle works</p>
</li>
</ul>
<p><strong>Note on code examples</strong>: Throughout this guide, <code>sequelize</code> is assumed to already be an initialized Sequelize instance, with <code>QueryTypes</code> and <code>Op</code> imported from <code>'sequelize'</code> alongside it, and <code>User</code>/<code>Product</code> are Sequelize models defined elsewhere in the app. Examples use Sequelize v6+ syntax (the current major version) since some of the APIs referenced here changed between major versions. You can adapt the syntax to the version and ORM you use.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-1-raw-query-escape-hatches">1. Raw Query Escape Hatches</a></p>
</li>
<li><p><a href="#heading-2-identifiers-that-cant-be-parameterized">2. Identifiers That Can't Be Parameterized</a></p>
</li>
<li><p><a href="#heading-3-second-order-injection-through-stored-data">3. Second-Order Injection Through Stored Data</a></p>
</li>
<li><p><a href="#heading-4-raw-sql-smuggled-into-normal-orm-calls">4. Raw SQL Smuggled Into Normal ORM Calls</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-1-raw-query-escape-hatches">1. Raw Query Escape Hatches</h2>
<p>Every major ORM ships an "escape hatch" for queries the query builder can't express cleanly: <code>sequelize.query()</code>, Prisma's <code>$queryRawUnsafe</code>, or TypeORM's <code>query()</code>. They exist for good reasons, like complex joins, window functions, and vendor-specific SQL.</p>
<p>The trouble starts when developers treat that escape hatch like the rest of the ORM, and interpolate user input directly into the string it builds.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>Here's a typical filtered report endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.get('/reports', async (req, res) => {
const { region } = req.query;
const results = await sequelize.query(
`SELECT * FROM sales WHERE region = '${region}'`,
{ type: QueryTypes.SELECT }
);
res.json(results);
});
</code></pre>
<p>The query builder never sees this string. It's handed straight to the database driver exactly as written.</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>A request like <code>?region=' OR '1'='1</code> turns the query into <code>SELECT * FROM sales WHERE region = '' OR '1'='1'</code>, which is always true. Every row in the table comes back, regardless of region.</p>
<p>Worse, because this code lives inside an ORM-based project, it often doesn't get the same scrutiny a raw <code>mysql.query()</code> call would in a non-ORM codebase. Reviewers assume the ORM already handled it.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Pass values through the replacement/binding mechanism the raw-query API already provides, instead of building the string yourself:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.get('/reports', async (req, res) => {
const { region } = req.query;
const results = await sequelize.query(
'SELECT * FROM sales WHERE region = :region',
{
replacements: { region },
type: QueryTypes.SELECT
}
);
res.json(results);
});
</code></pre>
<p>The fix isn't avoiding raw queries entirely, because sometimes you genuinely need them. It's never building the SQL string by hand when a binding mechanism is sitting right there.</p>
<p><strong>Never concatenate user input into a raw query string. Use the replacement/binding API your raw-query method already provides.</strong></p>
<h2 id="heading-2-identifiers-that-cant-be-parameterized">2. Identifiers That Can't Be Parameterized</h2>
<p>Parameterized queries protect <em>values</em>. They don't protect <em>identifiers</em> like table names, column names, or <code>ORDER BY</code> direction. Those have to be part of the SQL string itself.</p>
<p>That's why a dynamic sort feature is one of the most common places injection sneaks back into otherwise well-written ORM code.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>Here's a typical sortable list endpoint:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.get('/users', async (req, res) => {
const { sortBy = 'created_at' } = req.query;
const users = await sequelize.query(
`SELECT * FROM users ORDER BY ${sortBy}`,
{ type: QueryTypes.SELECT }
);
res.json(users);
});
</code></pre>
<p><code>sortBy</code> goes straight from the query string into the <code>ORDER BY</code> clause, with nothing in between.</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p><code>sortBy</code> looks like a harmless UI convenience until someone sends <code>created_at; DROP TABLE users; --</code>. Whether that exact payload runs depends on your database driver: Postgres's <code>pg</code> driver executes stacked statements like this by default, while MySQL's <code>mysql2</code> blocks them unless <code>multipleStatements: true</code> is explicitly set.</p>
<p>Either way, the underlying problem is the same: you have arbitrary SQL sitting in a position that was only ever meant to hold a column name.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Since identifiers can't be bound as parameters, the only safe option is an allowlist:</p>
<pre><code class="language-javascript">// Express.js - Secure
const ALLOWED_SORT_COLUMNS = ['created_at', 'name', 'email'];
app.get('/users', async (req, res) => {
const { sortBy = 'created_at' } = req.query;
const column = ALLOWED_SORT_COLUMNS.includes(sortBy) ? sortBy : 'created_at';
const users = await sequelize.query(
`SELECT * FROM users ORDER BY ${column}`,
{ type: QueryTypes.SELECT }
);
res.json(users);
});
</code></pre>
<p>Never pass user input through to an identifier position, even after "sanitizing" it first. Sanitization for identifiers is much easier to get wrong than for values.</p>
<p><strong>Identifiers can't be parameterized. If user input decides a column or table name, allowlist it, don't sanitize it.</strong></p>
<h2 id="heading-3-second-order-injection-through-stored-data">3. Second-Order Injection Through Stored Data</h2>
<p>This one catches teams off guard because the input <em>was</em> parameterized...the first time it was written to the database.</p>
<p>The injection happens later, when that already-stored value gets reused inside a different, unparameterized query.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>Here's a registration flow, and elsewhere in the codebase, an admin search feature:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
// Step 1: user registration, correctly parameterized
app.post('/register', async (req, res) => {
await User.create({ username: req.body.username });
res.json({ success: true });
});
// Step 2: somewhere else in the codebase, an admin search feature
app.get('/admin/search', async (req, res) => {
const user = await User.findByPk(req.params.id);
const results = await sequelize.query(
`SELECT * FROM audit_log WHERE actor = '${user.username}'`,
{ type: QueryTypes.SELECT }
);
res.json(results);
});
</code></pre>
<p>Step 1 is completely safe on its own. Step 2 is where things go wrong.</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>A username like <code>admin' OR '1'='1</code> passes through step 1 without any issue. Sequelize's <code>create()</code> parameterizes it, so it's stored exactly as typed. It sits in the database looking completely normal.</p>
<p>The payload only fires when step 2 pulls that value back out and drops it straight into a raw query string. "It's already in our own database" is not the same thing as "it's safe". It's still attacker-controlled if it originated from user input anywhere upstream.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Same fix as pattern #1. You bind the value instead of concatenating it:</p>
<pre><code class="language-javascript">// Express.js - Secure
// Step 1 (registration) is unchanged — it was already parameterized
app.get('/admin/search', async (req, res) => {
const user = await User.findByPk(req.params.id);
const results = await sequelize.query(
'SELECT * FROM audit_log WHERE actor = :actor',
{
replacements: { actor: user.username },
type: QueryTypes.SELECT
}
);
res.json(results);
});
</code></pre>
<p>The bug here is harder to spot than pattern #1, because the tainted data crosses a database round-trip before it becomes dangerous.</p>
<p><strong>Data from your own database isn't automatically safe. If it originated from user input anywhere upstream, it still needs to be parameterized everywhere it's used.</strong></p>
<h2 id="heading-4-raw-sql-smuggled-into-normal-orm-calls">4. Raw SQL Smuggled Into Normal ORM Calls</h2>
<p>Pattern #1 covered the <em>obvious</em> raw-query escape hatch, a method you reach for on purpose when you need real SQL. This one is sneakier: injection hiding inside a call that looks completely ORM-managed. It's not raw SQL, right? It's just one helper function among many.</p>
<h3 id="heading-how-to-identify-this-vulnerability-in-your-code">How to Identify This Vulnerability in Your Code</h3>
<p>Here's a typical filtered product listing:</p>
<pre><code class="language-javascript">// Express.js - Vulnerable
app.get('/products', async (req, res) => {
const { minPrice } = req.query;
const products = await Product.findAll({
where: sequelize.literal(`price > ${minPrice}`)
});
res.json(products);
});
</code></pre>
<p><code>Product.findAll()</code> looks like a fully safe, parameterized ORM call...right up until <code>sequelize.literal()</code> shows up inside it.</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p><code>literal()</code> tells Sequelize "don't touch this, insert it into the SQL exactly as written." Anything interpolated into it is exactly as injectable as pattern #1, just wearing a normal-looking ORM method as a disguise.</p>
<p>Send <code>?minPrice=0 OR 1=1</code> and the <code>WHERE</code> clause becomes unconditionally true: every row comes back, price filter or not. No stacked statement needed this time. It's a single boolean expression, so it works the same regardless of database driver.</p>
<h3 id="heading-how-to-fix-this-vulnerability">How to Fix This Vulnerability</h3>
<p>Stop reaching for <code>literal()</code>. Sequelize's own operator API already covers this case:</p>
<pre><code class="language-javascript">// Express.js - Secure
app.get('/products', async (req, res) => {
const minPrice = Number(req.query.minPrice);
if (!Number.isFinite(minPrice)) {
return res.status(400).json({ error: 'minPrice must be a number' });
}
const products = await Product.findAll({
where: { price: { [Op.gt]: minPrice } }
});
res.json(products);
});
</code></pre>
<p><code>Op.gt</code>, <code>Op.between</code>, <code>Op.in</code>, and the rest of Sequelize's operator API already cover the vast majority of cases people reach for <code>literal()</code> for, and they parameterize correctly by default. Validating that <code>minPrice</code> is actually a number, before it ever reaches the query, closes the gap even if a future refactor reintroduces <code>literal()</code> somewhere else.</p>
<p><strong>Grep for</strong> <code>literal(</code><strong>,</strong> <code>fn(</code><strong>, and your ORM's other raw escape hatches everywhere they appear in the codebase, not just inside the queries that are clearly "raw."</strong></p>
<h2 id="heading-summary">Summary</h2>
<p>For a quick recap, here's how the four patterns line up:</p>
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Root Cause</th>
<th>Core Fix</th>
</tr>
</thead>
<tbody><tr>
<td>Raw Query Escape Hatches</td>
<td>User input concatenated into a raw query string</td>
<td>Bind values via the replacement/parameter API</td>
</tr>
<tr>
<td>Identifier Injection</td>
<td>Column/table/sort input can't be parameterized</td>
<td>Allowlist allowed identifiers explicitly</td>
</tr>
<tr>
<td>Second-Order Injection</td>
<td>Stored data trusted because it was parameterized once</td>
<td>Parameterize every query, including ones using your own stored data</td>
</tr>
<tr>
<td>Literal Injection</td>
<td>Raw SQL smuggled into an otherwise ORM-managed call</td>
<td>Avoid <code>literal()</code>/<code>raw()</code>, use the ORM's operator API instead</td>
</tr>
</tbody></table>
<p>None of these four bugs takes a skilled attacker to find. Each one is just a value that never made it into the ORM's parameterization path.</p>
<p>What's worth making part of your workflow: parameterize values, allowlist identifiers, treat data pulled from your own database as no safer than a fresh request, and treat <code>literal()</code>/<code>raw()</code> calls as exactly as dangerous as a dedicated raw-query method (no matter how safe the surrounding code looks).</p>
<p>Fixing these gaps is only half the picture. Knowing how someone actually goes looking for them (and chains them together during a real assessment) is the half most developers never see.</p>
<p>If you're curious about that offensive perspective, this <a href="https://hackita.it/articoli/sql-injection-orm/">deep dive on SQL injection through ORMs</a> walks through exactly how these four patterns get exploited step by step in a live penetration test.</p>