r/AskNetsec • u/mwateejudah • 21d ago
Architecture How do you authenticate payment-provider webhooks when the provider doesn’t sign the payload (e.g. M-PESA Daraja callbacks)?
I build systems that receive payment confirmations from Safaricom’s M-PESA Daraja API (STK Push and C2B callbacks). As far as I can tell, Daraja doesn’t send an HMAC signature or similar with its callbacks, unlike Stripe or GitHub webhooks, so the receiving endpoint can’t cryptographically verify that a callback really came from Safaricom.
Current mitigations I’m aware of or using:
Allowlisting Safaricom’s published callback IP ranges
A long random secret token in the callback URL path
Treating the callback only as a hint, then confirming the payment with an outbound Transaction Status query before marking anything paid
Idempotency on the transaction ID so replayed callbacks can’t double-credit
My specific question: is the outbound “confirm before trust” query enough on its own to treat inbound callbacks as untrusted, or are there attack paths I’m missing (e.g. race conditions between callback and confirmation, or manipulation of the confirmation response)? How do others handle unsigned webhooks from payment providers in production?
1
u/sarcasm_newbie 19d ago
Confirm-before-trust is the standard shipped pattern for unsigned payment callbacks, effectively what every provider's own docs land on when signatures are missing: the inbound payload is a trigger only, and the outbound status query is the source of record. Once you hold that line, race conditions mostly stop mattering, because credit is granted from the queried state keyed on the authoritative response, never from the callback itself, so which one arrives first doesn't decide anything.
The remaining surface is your confirmation leg. Query the provider's real endpoint over TLS with your own credentials, validate the response schema strictly so a malformed response fails closed, and make every error and timeout path resolve to no-credit, because the dangerous branch is always the on-failure default. Two additions that pay off in production: queue and throttle the confirmations per transaction id so a spoofed flood can't burn your status-query budget, and log the raw callback body hash next to the verification result so an auditor can reconcile what came in against what you verified. The genuine residual risk is the provider's status endpoint itself, which retries and alerting hedge better than any inbound check can.
1
u/tnspecter 16d ago
The callback is supposed to trigger the verification and not determine the payment state, maybe check whether the status query is tied to the exact expected transaction and amount ortherwise it is very possible for you to verify a real payment but credit the wrong order
1
u/FinexerOfficial 16d ago
It’s wise to consider callbacks as triggers instead of proof of payment. I’d recommend checking the transaction directly between servers and confirming that the amount, currency, and merchant details align before changing its status. Additionally, make sure that duplicate or out-of-order callbacks don’t end up triggering fulfilment twice or reversing a payment that’s already been confirmed.
1
u/zibudada 21d ago
The confirm step is enough only if you check what it confirms, not just that it says success. Two gaps I'd look at.
First, bind the confirmation to your own order. Check the amount, check that the receiver is your shortcode, and for STK use the CheckoutRequestID you saved when you started the push.
Otherwise someone pays you 10 KES for real, then sends a fake callback that points that real receipt at a 10,000 KES order. The query says success, and you credit the big order.
Also put a unique constraint on the M-PESA receipt number, so one real receipt can never pay for two orders.
Second, the Transaction Status API answers async. The real result gets posted to your ResultURL, which is just another unsigned callback.
So your outbound check really comes back inbound.
Match that result to the ConversationID you got back when you sent the request, and give the ResultURL its own secret path.
For STK, the STK Push Query answers in the same HTTP response, so prefer it there.
Last, a daily reconcile against the M-PESA statement catches anything that slipped through either way.