Skip to content
NOSTREL
Writing

Anyone could have created money with curl, including us

Safaricom does not sign its callbacks. What that means for every M-Pesa integration in Kenya, and why authenticating the endpoint is the smaller half.

8 minute readSecurityNOSTREL engineering

There is a line in our repository that used to say production hardens the M-Pesa callback endpoint "with source-IP allowlisting and an unguessable path token".

No code did either.

The comment had been written, committed, reviewed and left alone, and nothing in the codebase disagreed with it. Meanwhile the endpoint itself did what you would expect: it received a callback from Safaricom, matched the transaction id in the body, saw a success code, and credited the merchant's ledger.

That endpoint is on the public internet, because that is where Daraja calls from. And Safaricom does not sign its callbacks.

What "does not sign" means in practice

There is no HMAC. There is no mutual TLS. There is nothing in the request that proves the sender is Safaricom rather than somebody with a terminal and your URL. The entire authentication story for an M-Pesa callback, as shipped by most integrations in Kenya, is that the URL is a bit hard to guess.

So the attack is not clever. You raise a collection for whatever your tier allows, to a phone number you control. You never pay it. You post a success callback to the endpoint yourself. Your balance goes up. You withdraw.

The amount cannot be invented by the forger, because we take it from our own record of the collection rather than from the callback body. That is a genuinely useful property and it narrows the attack to "money you asked for" rather than "any money at all". It does not prevent it. The attacker simply chooses the figure earlier, at the point where they create the collection.

The B2C side had the same shape. Forge a result and an undelivered payout reads as paid. Forge a failure and a reservation is released.

The fix everybody reaches for

Authenticate the endpoint. Allowlist the provider's addresses, put an unguessable token in the path, check both before the request reaches application code.

Do that. It is correct, it is cheap, and it stops the opportunistic version of this attack dead.

It is also the smaller half of the fix, and treating it as the whole fix is how you end up with a system whose integrity rests on a secret in a URL.

Think about what has to stay true for that defence to hold. The path token has to never appear in a log, a bug report, a screenshot, a proxy's access log, a referrer header or a support ticket. The address list has to stay current as the provider's infrastructure changes, and the failure mode of it being stale is that real payments stop arriving, which creates pressure to widen it. Every one of those is a perfectly ordinary thing that happens to perfectly careful teams.

A control that depends on a secret staying secret forever is a control with an expiry date you cannot see.

The fix that actually holds

Stop believing the message.

A success callback now does not credit anything. It moves the collection into a state called awaiting_confirmation, which means exactly what it says: something has told us this succeeded and we have not yet established that independently. No balance moves. No fee posts. No webhook goes out saying the payment succeeded, because we do not yet know that it did.

Then we ask Safaricom ourselves, in an exchange we initiated, whether that transaction exists and succeeded.

  • Yes, and the collection moves to success, the ledger posts both sides, the fee is taken and your webhook fires.
  • A definite no, and it fails, and the fee comes back.
  • No answer at all, and it stays parked. A scheduled job tries again. The one outcome we will not produce is a balance we cannot defend.

The important property is that this holds even if the first defence is bypassed entirely. Someone who finds the path token, or who is calling from an allowlisted address, still cannot make us credit anything, because the credit does not depend on their message. It depends on an answer we went and asked for.

Defence in depth gets used as a slogan for "we added several controls". The useful version is narrower: the second control has to not share a failure mode with the first. An allowlist and a path token share one, which is that a secret can leak. Asking the provider directly does not share it with either.

What it costs

Latency, a little. The confirmation is an extra round trip to Daraja, and it sits between the payer entering their PIN and your webhook firing. In practice it is fractions of a second against a flow whose slowest step is a human being finding their phone, so it is lost in the noise.

An extra state, which is the more interesting cost. Integrators now see awaiting_confirmation in the lifecycle, and anyone whose code treats "not success and not pending" as failure has to change. We think that is a reasonable thing to ask, and we think a lifecycle that shows you the check is better than one that hides it, because the hidden version is indistinguishable from not doing the check at all.

And one more round trip of quota against the provider, per payment. Fine.

The part that generalises

The specific bug is ours. The shape of it is everywhere.

Any time a system changes state because an unauthenticated party said something happened, the question is not "how do we authenticate them better". It is "what would we have to do to not need to believe them". Sometimes there is no answer and authentication is all you have. For payments there usually is an answer, because the authoritative record lives at the provider and you can go and read it.

The other part that generalises is the comment. Somebody had thought about this properly, written down the right answer, and the code never caught up. Nothing in the repository disagreed, no test failed, and the comment aged into documentation of a protection that did not exist.

If you take one practice from this, take that one: a comment describing a security control is a claim, and a claim with no test behind it is a wish. We now have a test that posts a forged callback and asserts that nothing moves.


The full write-up of this finding, along with five others and the three that were critical, is on the security page. The lifecycle it produced is documented on collections, and the state is called awaiting_confirmation precisely so that nobody has to read a blog post to find out what it means.

Disagree with any of this?

Genuinely. If something here is wrong, or right for the wrong reason, we would rather be told than keep it published. Corrections get made and credited.