
A few weeks ago I reported to a private bug bounty program a vulnerability that I found quite curious, and which I think deserves a post for two reasons: on the one hand, the bug itself is a very clear example of why any data travelling in a QR code should be signed or encrypted, and on the other, the response I got from the program, which in my opinion was insufficient and which we will discuss later.
Scope
The target was an online store that allows in-store order pickup (what is usually known as Click & Collect). The flow is the typical one: you buy online, choose “pick up in store”, pay (or indicate that you will pay at pickup) and the application emails you a QR code that you show at the counter so they hand over your order.
So far nothing unusual. The problem was in what that QR contained.
QR code
When decoding the QR that arrived in my inbox, I found a string like this:
ORD#id=a7f2bc10d84e4f329b1c5e8a0d6f7c42|number=OR-78451209|orderTime=2026-08-14T16:43:43+02:00|paymentId:3
To put it in context, here is what each field is:
ORD#id=<order_uuid>|number=<order_number>|orderTime=<timestamp>|paymentId:<payment_method_id>
No signature. No HMAC. No encryption. Plain text, with a completely predictable structure.
The field that caught my attention was paymentId (the payment method identifier). After placing two test orders I found out that:
- paymentId:3 -> Cash payment on pickup (order not paid at the time the QR was issued)
- paymentId:21 -> PayPal (order already paid at the time the QR was issued)
In other words, the QR the customer takes to the store was itself declaring whether the order had already been paid or not.
Exploitation
The PoC is so simple that anyone could do it, you only need to pay attention to this:
- I place a real order paying with PayPal, and I receive the QR. I decode it and confirm that it carries paymentId:21.
- I place a second order, this time choosing cash payment on pickup. I receive another QR with paymentId:3.
- I decode the second QR and change paymentId:3 to paymentId:21, leaving everything else the same:
ORD#id=a7f2bc10d84e4f329b1c5e8a0d6f7c42|number=OR-78451209|orderTime=2026-08-14T16:43:43+02:00|paymentId:21
I regenerate the QR with any online tool. Visually it is indistinguishable from the original.
If the store scanner trusts the paymentId field to mark the order as paid, I walk away with the product without having paid a single cent. And the loss would only be detected later, not at that moment.
Why a QR should never be plain text
This is what stands out the most to me about this case, and why I think it is worth publishing. A QR code is not a secure channel. It is a visual transport medium, perfectly readable and modifiable by anyone with a phone and thirty seconds of time. Treating it as if it were a secure token can be very dangerous.
The correct options are basically two:
- Make the QR an opaque identifier (a random UUID that the server resolves against the database at the time of scanning). The QR carries no state, only a reference. The state is queried on the server.
- Sign the payload with an HMAC using a server-side secret. That way, if someone alters any bit of the content, the signature stops being valid and the scanner detects it.
In this case the logical choice is option 1: the QR should carry only the order ID and the scanner should query the backend for that order’s status (paid, pending, cancelled, already collected…)
The program’s response (and my opinion)
After two months under review, the program closed the report as Informative, with this justification:
Hello,
thank you for your feedback.
The payment ID is not used for any backend operation. It is provided only as informational data for the customer, and no backend changes are made based on this value.
Payment is always verified. Our security principle remains the same: 'Never trust the user'. Any user-controlled input is therefore considered untrusted and must be properly validated and verified before being processed.
Best regards.
Personally, I do not fully believe it, and this is where I want to reflect a little.
The problem is that as a hunter I have no way of verifying what they are telling me. I do not have access to the store scanner’s code, I do not have access to the backend, and I cannot physically go to the establishment to test the modified QR (I am not going to travel to Germany to check it, they have no stores in Spain). I have to take their word for it.
Conclusion
If you audit a flow with QR codes, always decode them. A QR is text, just like any HTTP parameter. If it carries unsigned state information, it is a bug.
And of course, if someone on the defensive side reads this, never put state in a QR without signing it. It is exactly the same mistake as putting the product price in a hidden field of the checkout form, only with pixels.
