Late in a seven-release payment-debugging day, I was reading a payment-receipt handler I’d been patching for hours, and I caught it building this:
"invoiceID = " + invoiceIDThat string went straight into a SQL WHERE clause. And invoiceID came directly off the body of a payment-gateway POST.
I’d been so deep in “why is the receipt finding the wrong registration” that I’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 isn’t that string concatenation into SQL is bad. Everybody knows that. It’s why I’d stopped treating this endpoint as dangerous in the first place.
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 my head, that POST was “the gateway calling us.” A trusted internal handshake. Authorize.Net talks to my relay page, I read the fields, done. And once something is framed as trusted, every instinct that would normally fire on request data into a query goes quiet. I wasn’t validating x_invoice_num because, in the story I was telling myself, 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 I’d talked myself 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, not who you think sent 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 real flow. The mistake was taking the echoed value, which is a number I control, and trusting it to still be that number when the POST arrives. On a public endpoint, “the value I set” and “the value that shows up” are not the same value.
The one-line fix
I didn’t rewrite the data layer, and you shouldn’t either mid-incident. This is an old classic-ASP codebase 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: + invoiceIDThat’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 quiet 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 I’d 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 quietly 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 isn’t “assume Authorize.Net,” it’s 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 me into that file was real and customer-facing and got its own fix. But the line I’m actually glad I caught was the one nobody filed a ticket about. It was an injection vector hiding behind a payment processor’s good name, on a public URL anyone could POST to, opened not by a code change but by a quiet assumption that the gateway was a friend.
The gateway usually is a friend, but the endpoint has no way to know that, and the query should never assume it.
Related
- Shipping a Throwaway Relay Page to Debug a Payment Integration in Prod: the production debugging path that exposed this boundary mistake
- x_trans_id is NOT your invoice number: an Authorize.Net Direct-Post receipt bug: the field-identity mistake that sat alongside it