Skip to content
mroot.co
← All writing
Case StudiesJun 26, 2026 · 2 min read

How to Fix a SQL Injection Vulnerability Without Breaking Production

A search endpoint on Atlas Market built its query with string interpolation. Here's how I proved the vulnerability with a failing test, fixed it with parameterized queries, and shipped without breaking a single caller.

Project: Atlas Marketopen →
1
critical vuln closed
0
breaking changes
100%
endpoints parameterized

The listing search on Atlas Market worked fine — until you typed a single quote into the box. That one character was enough to prove the whole endpoint was building SQL out of raw user input.

The context

Atlas Market lets anyone browse a live grid of listings. The search box hit an endpoint that filtered by title. The bug was not exotic: the query was assembled with a template literal, so the user controlled part of the SQL string, not just its data.

listings.js — the vulnerable endpoint
// search endpoint — DO NOT ship thisapp.get("/api/listings", (req, res) => {  const q = req.query.q;  const sql = `SELECT * FROM listings WHERE title LIKE '%${q}%'`;  db.query(sql).then(rows => res.json(rows));});
String interpolation is the tell
Any time untrusted input reaches a query through a template literal or + concatenation, treat it as an open door — regardless of how "safe" the surrounding code looks.

Proving the bug first

Before touching the fix, I wrote a failing test that encoded the exact attack. This does two things: it documents the vulnerability as an executable fact, and it guarantees a regression will be caught if anyone reintroduces the pattern.

listings.test.js — red before green
it("rejects SQL control characters in search", async () => {  const res = await request(app)    .get("/api/listings")    .query({ q: "'; DROP TABLE listings;--" });  expect(res.status).toBe(200);  await expect(tableExists("listings")).resolves.toBe(true);});

The fix — parameterized queries

The fix is not to escape or sanitize the input by hand — it is to stop building SQL out of it at all. Parameterized queries send the statement and the values on separate channels, so the driver never treats input as code.

listings.js — parameterized
app.get("/api/listings", (req, res) => {  const q = `%${req.query.q ?? ''}%`;  db.query(    'SELECT * FROM listings WHERE title LIKE $1 LIMIT 50',    [q],  ).then(rows => res.json(rows));});

Note the extra LIMIT 50 — the old endpoint would happily stream the entire table, so remediation was also a chance to cap the blast radius of a scraping attempt.

The impact

  • The failing test went green; the DROP TABLE payload became inert data.
  • A repo-wide grep for template-literal SQL surfaced two more endpoints, both fixed the same way.
  • Callers were untouched — same route, same response shape, zero breaking changes.
  • Added a lint rule to flag raw query strings so the pattern cannot come back quietly.

A bug is not fixed until a test would fail without the fix.

— my rule for security work

What I would do next

Parameterization closes injection, but defense in depth means going further: least-privilege database roles so the app user cannot DROP anything, query timeouts, and centralized logging on the search path to catch probing early.

If you're auditing your own stack, another easy win in the same spirit is dropping mandatory password rotation — see why the 90-day rotation policy needs to die. And if you'd rather have someone else run this kind of audit against your app, that's what I do for clients.

Marc Delacruz — full-stack, security-minded.Get in touch →