All posts
paymentsAPI securitywebhooksverification

Make both payment references vouch for each other

A verified transaction proves that someone paid. It does not prove they paid for the thing you are about to mark as paid.

Most hosted checkout flows end the same way. The customer pays on the provider's page, gets redirected back to your app, and your app calls your own API with two identifiers: the reference you created when you started the checkout, and the provider's transaction id. Your API asks the provider about that transaction, checks that it succeeded, and marks your payment as paid.

Every step there sounds careful. The server does not trust the client's word that the payment succeeded. It goes and asks the provider.

The trouble is what it asks. "Did transaction 8812 succeed?" is a question about the provider's record. "Did the customer pay for order A?" is a question about yours. The two identifiers arrive side by side in the same request body, and it is very easy to write code that checks each one on its own and never checks that they describe the same payment.

We closed exactly this gap in a marketplace backend last week. Here is the shape of it, sanitised, and the checks I now expect in any verification endpoint.

The version that looks fine

async verifyPayment(ourRef: string, providerTxId: string) {
  const payment = await payments.findOne({ where: { ourRef } });
  if (!payment) throw new NotFound();
  if (payment.status === "success") return { success: true };

  const tx = await provider.verify(providerTxId);
  if (tx.status !== "successful") return fail(payment);

  if (
    Number(tx.amount) < Number(payment.amount) ||
    tx.currency !== payment.currency ||
    (tx.meta?.orderId && tx.meta.orderId !== payment.orderId)
  ) {
    return fail(payment);
  }

  return markPaid(payment, providerTxId);
}

Read it as an attacker would. You control both arguments. The lookup finds any pending payment by reference, whoever it belongs to. The provider check proves that some transaction succeeded, for at least the right amount, in the right currency. The only link between the two records is an order id in provider metadata, and that check passes whenever the metadata is missing.

So a genuine successful transaction of equal or greater value, one without that metadata field, can be presented alongside someone else's pending reference and mark their order paid. Nothing was forged. Every value the server checked was true. It simply never asked the one question that mattered.

The checks that close it

1. The provider's record must name your reference. Every hosted checkout lets you attach your own reference when you create the payment, and the provider returns it when you verify. Compare it, exactly, with the payment you looked up. This is the check that binds the two identifiers together. Without it, amount and currency matching is just a coincidence test.

if (String(tx.reference ?? "") !== payment.ourRef) return fail(payment);

2. Your record must belong to the caller. Scope the lookup by the authenticated user, not only by reference. References are often guessable, logged in URLs, or visible in support screenshots. An unscoped lookup turns any leaked reference into something another account can act on.

const payment = await payments.findOne({ where: { ourRef, customerId: user.id } });

3. An optional check is not a check. if (field && field !== expected) reads like validation, but it is a switch the other party can turn off by leaving the field out. If a value is part of the binding, require it. If you cannot require it, because older payments were created without it, stop counting it as a defence and make sure something else does the work.

4. One provider transaction should credit one payment. Store the provider's transaction id on the payment you mark paid, and put a unique constraint on it. Then even a logic bug further up cannot let one real transaction settle two orders. The database will refuse the second write, and you will hear about it.

5. Validate the shape before you spend a network call. Reject missing or blank identifiers with a 400 before calling the provider. It keeps junk out of your logs and your provider's rate limits, and it makes the error honest: "you did not send a reference" is a different failure from "that payment does not match".

Why this keeps getting written

Part of it is that the redirect handler is written last, in a hurry, after the webhook or the checkout creation has taken most of the attention. Part of it is that the provider's verify call feels like the security boundary, so everything after it gets less scrutiny.

The bigger reason is that two-identifier endpoints are unusual. Most handlers take one id and look it up. When a request carries two ids that should refer to the same thing, the natural instinct is to validate each id, not the relationship between them. That relationship is the whole point of the endpoint.

The same pattern shows up outside payments. A "confirm email change" request that carries a token and a user id. A "download attachment" request that carries a message id and a file id. A webhook that carries an event id and an object id. In each case, check that the second identifier is actually reachable from the first, under the caller's authority, before acting on either.

Where the redirect sits

None of this replaces a signed webhook from the provider. The redirect back to your app is a hint that a payment may have finished. It is convenient for showing the customer a result quickly, but it is driven by a browser you do not control, and it may never arrive at all. The webhook, verified with the provider's signature and passed through the same binding checks, is the stronger path to marking something paid. The redirect handler should reach the same verdict using the same code, and should be safe to call twice, or a hundred times, with the same arguments.

The fix itself was a few lines, plus tests that present mismatched pairs: a successful transaction that carries a different reference, a real reference sent from another customer's session, and a request with the reference left out. The tests are the part worth copying. They describe the attack in the terms the code has to defend against, and they will fail loudly the next time someone refactors the lookup.

0 comments

Join the conversation

Get the next dispatch

New writing on software architecture, AI systems, and shipping production software, sent by email. Unsubscribe anytime.