How ORMs Still Let SQL Injection Through (and How to Close the Gaps) — Opportunihub
Course Remote

How ORMs Still Let SQL Injection Through (and How to Close the Gaps)

Hackita · Remote

At a glance

Type
Course
Organisation
Hackita
Location
Remote
Work mode
Remote
Deadline
Rolling / not stated
Posted
29 Sep 2026

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) =&gt; { 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) =&gt; { 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) =&gt; { 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) =&gt; { 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) =&gt; { 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) =&gt; { 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) =&gt; { 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) =&gt; { const { minPrice } = req.query; const products = await Product.findAll({ where: sequelize.literal(`price &gt; ${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) =&gt; { 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>

How to apply

  1. 1 Read the full details above and confirm you meet the eligibility criteria.
  2. 2 Prepare your documents — an updated CV, and any cover letter, proposal or certificates required.
  3. 3 Click Apply on official site to complete your application on Hackita’s official page.
  4. 4 Submit as early as possible — many close once filled.
Apply on official site

Sourced from freecodecamp. Always verify details on the official website. Opportunihub never charges you to apply.

Frequently asked questions

How do I apply for How ORMs Still Let SQL Injection Through (and How to Close the Gaps)?

Review the full details and eligibility on this page, prepare your documents, then use the “Apply on official site” button to complete your application on Hackita’s official page.

Is this opportunity remote or location-based?

This opportunity is remote-friendly and open to applicants who can work from anywhere.

Is How ORMs Still Let SQL Injection Through (and How to Close the Gaps) free to apply for?

Opportunihub lists this Course for free. Legitimate Courses do not ask for payment to apply — never pay a fee to submit an application.