It was a real, customer-facing problem, and it got its own fix. Then one line made us stop.
The page took an invoice number from the payment service’s message and used it directly to ask the database for a registration. It never checked that the value was a number at all. In ordinary language, it was like reading the number on a package label and using it to open a filing cabinet without confirming it was a number. That line carried a SQL injection risk. The name sounds bigger than it is. It means somebody can send words where a number belongs, and those words can change the question your database thinks it has been asked.
Why we had missed it
We had been telling ourselves a story. The payment service processed the charge, then sent information back to a page that showed the receipt. If the payment service was the expected sender, surely the number inside its message was safe. It was not a private hallway. It was a public front door.
The page had to be reachable from the outside so the payment service could send its result. But anything else on the internet could knock on that same door. Nothing proved the message came from the payment service: no signature configured, nothing verified. The address was not secret. Anyone who knew it could send their own message with their own invoice number, and our code would have treated it exactly like the real thing.
The hours on the receipt mix-up cost us more than time. They cost the attention that goes with being deep in one emergency. We were so busy asking why the page found the wrong registration that we never asked whether an outsider could tell the page which registration to find.
There was a second thing in that handler. Two different numbers, the registration ID and the payment provider’s own transaction ID, had at some point been treated as interchangeable, as a kind of safety net. Treating them as the same thing was part of what let the deeper problem sit there unnoticed for as long as it did.
The five lines
Once we saw the boundary, the fix was small. The registration number had one hard rule: it had to be a positive whole number. So the page now converts the incoming value to a number and stops if it isn’t one. A value made of letters or symbols never reaches the database. A real registration number works exactly as before.
That mattered for more than security. Before the check, a bad value could produce a database error, or a lookup of the wrong record, with no warning. After it, the page gives one plain message, that payment could not be confirmed and to contact the administrator, and nobody is ever shown a receipt that belongs to someone else.
The five lines work because they put the trust decision at the one place it belongs, the moment the invoice number arrives at the door. Before them, a stranger’s message went straight into the registration lookup. After them, only a positive whole number can reach the database.
We did not rebuild the whole integration in the middle of a payment problem, and we wouldn’t recommend it either. The short-term move was a check at the door.
The check does not prove who sent the message. That job belongs to the signature the payment provider offers, and it is the reason the door is only half closed. Making the door prove who is visiting, using that verification method, is the longer job, and it came after.
What to ask your team or vendor
Ask whether every public page that receives a message from a payment service checks that the incoming values are what they claim to be, and whether it verifies the sender using the provider’s signature. A basic input check closes the worst gap quickly. Sender verification is the job still to finish.