Blog · 9 min · Server code

When the scanner says injection: what error-based SQLi and reflected XSS actually mean

These two findings have the worst reputation-to-probability ratio in security. They are also some of the most fixable. Here is what the probe actually did, what the result proves, and the two patterns that close both.

September 2026 · Zoltra · No account needed to use this fix

When Zoltra reports a generic SQL injection or a reflected XSS, we are not running a weaponized exploit. We are checking whether untrusted input reaches two specific places it should never reach: a SQL parser, and an HTML response. That check is small on purpose, because the point is to find the seam, not to walk through it.

Error-based SQL injection: what it proves

The probe sends a value that makes a poorly-parameterized query break — producing a database error where the app expected a result. If the response contains the database error shape, a value from the URL is reaching SQL directly.

What it proves: your input handling has at least one raw path to the database. What it does not prove: that anyone can read your data. Some drivers throw errors before any data is exposed. The finding is a smoke alarm, not a fire report. But where there is an error, there is usually a seam behind it, and the seam does not care that our probe was polite.

Reflected XSS: what it proves

The probe puts a harmless marker into a parameter and checks whether it comes back inside the HTML response, unescaped, in a place where a script would run. If it does, the page is echoing user input to the next visitor as code instead of text.

Reflected means the value comes back in the response to the same request — the classic crafted-link shape: send a victim a URL that carries the payload. It is not stored XSS (where your database keeps the payload and every page view runs it), which is more damaging and which a public-surface probe generally cannot see. If reflected input lands unescaped, assume the same habit exists wherever else you render user data.

Why this still happens in 2026

Modern frameworks auto-escape by default, and ORMs hand you parameterized queries for free. Then real code happens. A raw SQL call written by hand for one report. A template string that assembles HTML for an email or an embed card. dangerouslySetInnerHTML for one rich-text field. A helper that does "SELECT ... WHERE id = '" + id + "'" because the deadline was Tuesday.

None of those are exotic. They are what a normal codebase looks like after two years of shipping. The fixes are known and boring, which is the good news.

Fix one: parameterized queries, everywhere

The database should never see your user's input as part of the query text. It should see it as a value. Every driver has a form for this; use it even when the input feels safe.

// wrong — id is part of the SQL text
const rows = await sql(`SELECT * FROM reports WHERE id = '${id}'`);

// right — id is a bound parameter
const rows = await sql("SELECT * FROM reports WHERE id = $1", [id]);

// ORM: the same rule applies to .raw() escape hatches
// if you must concatenate, concatenate identifiers from an allowlist,
// never values from a request.

Then sweep for the escape hatches. Search for raw(, execute(, query( with template literals, and any place SQL meets params.next. That search takes ten minutes and finds the real bug set.

Fix two: escaping happens at the point of render

For XSS, the fix is not one scanner-proof regex on input. It is making sure output is encoded where it is placed into HTML. React, Vue, Svelte, and every modern template engine do this for you — right up until you reach for the intentional bypass.

// React escapes this. Usually the right thing.
<p>{userComment}</p>

// This does not escape. Treat every use as a security decision.
<div dangerouslySetInnerHTML={{ __html: userComment }} />

// If you truly need rich text: sanitize it server-side with a
// maintained library, then render the sanitized value. Never
// hand-roll the sanitizer with regex.

And add a strict Content-Security-Policy as the backstop. It will not fix an escaping bug, but it will often stop an injected script from executing. That is the one place where the security-headers guide and this one touch — if you have both findings, fix the escaping first, then ship the CSP.

How to verify with a retest

The honest verification is the same probe that found it: re-run the request and check that the response no longer contains the marker unescaped, and that the database error no longer appears. In Zoltra, a finding is only marked verified after a fresh check passes. You can also do it by hand:

# reflected XSS: the marker should come back escaped (%3C is <)
curl -s "https://your-app.example/search?q=zz-marker-%3Cb%3E" | grep -o "zz-marker-[^<]*"

# SQLi: the crafted value should return a normal response or a clean
# 400 — never a database error
curl -s "https://your-app.example/api/report?id=1%27" | head -20

How this drifts back

One hand-written query, added during a busy week, in a file nobody thinks of as security-relevant. That is the whole drift story. The scan found the public seam; the private ones are born when someone reaches past the ORM because the ORM was slow that day.

Make the safe path the easy path. If your team has one db.query wrapper that always parameterizes, and a lint rule that flags raw SQL string building, the class stays closed. If the only protection is that everyone remembers, it reopens.

One honest limit

A public scan sees what an unauthenticated visitor sees. If your injection point is behind a login, the probe will not find it. That does not make those paths safe — it makes them unmeasured. The same two patterns protect code you have not tested yet, which is the reason to fix the class rather than the example.

You can still ship this fix yourself. With GitHub connected, Zoltra can also prepare the repair as a pull request, leave it for your review, and verify the live result after deployment.


Related: Missing security headers — where CSP, the XSS backstop, is covered in detail.