Late in a seven-release payment-debugging day, we were reading a payment-receipt handler we’d been patching for hours, and we caught it building this:

"invoiceID = " + invoiceID

That string went straight into a SQL WHERE clause. And invoiceID came directly off the body of a payment-gateway POST.

We’d been so deep in “why is the receipt finding the wrong registration” that we’d stopped seeing the bigger problem sitting right next to it. The value wasn’t sanitized, wasn’t coerced, wasn’t bounded. It was a raw field off an HTTP request, concatenated into a query, on a page that processes real credit-card confirmations.

What makes this worth writing about is why we’d stopped treating this endpoint as dangerous in the first place. Everybody knows string concatenation into SQL is bad.

The “trusted” framing is the bug

This was an Authorize.Net Direct Post integration. If you haven’t worked with the Direct Post Method, the shape is: the gateway processes the charge, then POSTs the result back to a relay URL on your site as application/x-www-form-urlencoded. You read the x_-prefixed fields off that POST and render the receipt. No server-to-server callback, no shared secret in the request, no signature you’re checking on every field. The gateway just posts form data at your URL and you build a page from it.

In our heads, that POST was “the gateway calling us.” A trusted internal handshake. Authorize.Net talks to our relay page, we read the fields, done. And once something is framed as trusted, every instinct that would normally fire on request data into a query stops firing. We weren’t validating x_invoice_num because, in the story we were telling ourselves, Authorize.Net sent it.

Here’s the part that breaks the story. The relay URL is public. It has to be, because the gateway has to be able to reach it from the outside. Authorize.Net does offer a way to prove a relay response is theirs: configure a Signature Key and it sends an x_SHA2_Hash you can verify. We had not configured one, and we were not checking anything, so in our integration nothing in that POST proved where it came from. Anyone who knows the URL, and the URL is not a secret, can open a terminal and POST whatever form fields they want:

curl -X POST https://example.com/payment/relay \
-d "x_invoice_num=1 OR 1=1" \
-d "x_response_code=1"

The gateway “trust” is vibes. There’s no authentication boundary there at all. It’s the same exposure as any querystring param on any public page, except we’d talked ourselves out of seeing it because a payment processor was the expected caller.

That’s the hot take: a callback from a trusted third party is not a trusted input. The caller you expect and the caller you get are different things on a public endpoint. The only thing that makes input trustworthy is that you validated it.

What the value actually was

Worth a beat, because it shows how the field even got into a query. In Authorize.Net’s Direct Post, x_invoice_num carries your identifier, the thing you set when you started the transaction. Here that was the record ID for the registration. x_trans_id is the gateway’s own transaction ID, which belongs in a different column entirely. (Conflating those two was a separate bug the same day, and yes, the original code had OR’d them together as a “safety net,” which is its own lesson.)

So the receipt page legitimately needs to look up a record by the ID echoed back in x_invoice_num. That’s the flow. The mistake was taking the echoed value, which is a number we control, and trusting it to still be that number when the POST arrives. On a public endpoint, “the value we set” and “the value that shows up” are not the same value.

The one-line fix

We didn’t rewrite the data layer, and you shouldn’t either mid-incident. This is an older web application running server-side JScript on IIS, and parameterized queries are not uniformly threaded through it. Mid-firefight, the realistic move is to validate and coerce at the boundary, not to refactor the whole query layer while customers can’t get receipts.

The registration ID is a positive integer. That’s a hard constraint, which makes the guard trivial. Coerce to a number, reject anything that isn’t a sane positive integer, and interpolate the number, not the string:

var invoiceIDNum = Number(invoiceID);
if (isNaN(invoiceIDNum) || invoiceIDNum <= 0) {
this.hasError = true;
this.errorMessage = "Payment cannot be confirmed. " +
"Contact the site administrator for more information.";
return;
}
// later, in the lookup:
"invoiceID = " + invoiceIDNum // was: + invoiceID

That’s it. Number("1 OR 1=1") is NaN, so the injection attempt bounces off the bounds check and never reaches the query. A legitimate integer value sails through as an actual number. The interpolation now puts a number into the SQL, and a number can’t carry a SQL payload.

There’s a correctness win in here too: a malformed or hostile x_invoice_num now produces a clean “payment cannot be confirmed, contact the administrator” message instead of either a database error or a silently-wrong lookup. The guard is a security fix and a robustness fix in the same five lines.

What we take from this

The word “callback” is doing dangerous work. When you describe an endpoint as a webhook, a callback, a relay, a postback, you’re naming it after the caller you expect. That name implies authentication that usually isn’t there. The HTTP layer doesn’t know it’s a “callback.” It’s a public POST handler that happens to usually be hit by a partner.

If you actually want the trust, build it. The right long-term answer for a gateway relay is to verify the thing is really from Authorize.Net, via a response signature, a shared secret you check, an IP allowlist, MD5 hash validation, whatever the gateway offers. Until that’s in place, the input is anonymous, and you treat it like any other anonymous input. Validating the field is the floor either way, because even a properly authenticated partner can send you a malformed value.

The receipt bug that pulled us into that file was real and customer-facing and got its own fix. But the line we’re glad we caught was the one nobody filed a ticket about: an injection vector hiding behind a payment processor’s good name, on a public URL anyone could POST to.

Takeaway

Treat every field arriving at a public endpoint as anonymous input, even when a payment gateway is the expected caller. The gateway usually is a friend, but the endpoint has no way to know that, and the query should never assume it.