API security · business logic

Breaking a Payment API

I tested the API behind a bill-payment service. The bugs that mattered were in the workflow: the order of the steps, what a simple “read” quietly changed, and what happened when two requests arrived at once. Here is how I found six of them by treating the API as a state machine.

6 findings Payment aggregator State-machine method ~8 min read
01 · The setup

Three parties, one target

A bill-payment aggregator sits in the middle of a chain that moves money. On one end is a merchant: here, a bank whose app lets customers pay bills. In the middle is the aggregator, one API that takes each payment and passes it on. On the far end are the billers, the top-up, electricity, insurance, and mobile-money services that actually fulfil the payment.

I was allowed to test the aggregator only. Not the bank’s app, not the customer, not the billers. So the whole test surface is the link between the merchant and the aggregator.

Every request carried two sets of credentials. One identified the merchant (in the headers), and one picked the biller (in the body). The second set looked like a second password. It wasn’t. Those biller keys were the same for every merchant, and were handed out by a public list endpoint. So the only real gate is the merchant credential. Worth remembering.

Before sending any live traffic, I had a saved copy of the API’s requests and responses to read. An hour with those gave me the map that everything below is built on.

IN SCOPE: the aggregator & its inbound interface Customer device · out of scope Merchant a bank · out of scope Aggregator THE TARGET holds the API we test Billers top-up · utility insurance · mobile-money back ends · out of scope ①② ② ① merchant credentials, sent in the request headers ② biller credentials, sent in the request body (they pick which biller the aggregator forwards to)
02 · The method

Treat the API as a state machine

At its core the API is a payment that moves through a few states. It is created, then submitted, then either settled or failed. Drawing that little machine makes the rules it must always obey obvious. Two of the six findings are right there in the picture: places where the payment made a move that should be impossible.

lookup PENDING created, unpaid SUBMITTED sent to biller SETTLED paid, final FAILED dead, final pay biller confirms a read or a failed pay kills it reported paid before pay is ever called normal flow illegal move I could trigger

The state machine covers the workflow. A payment API also owes a few plainer promises no matter what: it should not let one caller flood it, it should not store dangerous input, and it should bound every amount. That is six promises in all. I went through the list and tried to break each one.

03 · The findings

Six promises, and how each one broke

Finding 1

A single request permanently blocks a pending payment

The promise: a live payment survives a status check or a failed attempt.

What I did. Created a pending payment. Then, on that same transaction, I either just checked its status, or sent a payment that got rejected (wrong biller keys, or a missing field).

What happened. Any of those requests consumed the transaction. Its ID could no longer be used to pay. The correct payment sent afterwards was rejected, and the transaction was stuck in a dead, unpayable state.

create a payment                     → PENDING
check its status  OR  pay w/ wrong keys  → the transaction is consumed
pay with the correct keys            → rejected, the payment is dead

I ruled out a timeout. Paying immediately worked. Adding the same delay by checking a different transaction worked. Only touching this transaction killed it.

Why it matters. It breaks even with no attacker involved. Checking a transaction’s status before submitting the payment is a completely normal thing for a merchant to do, and here it destroys the very payment it was checking. It is also a denial of service: because any request that reaches a pending transaction consumes it, anyone who knows or guesses a transaction ID can permanently block a customer’s payment with a single cheap request that does not even need to succeed. A benign status check, or one wrong key, is enough.

Finding 2

Two requests at once slipped past the rules

The promise: one transaction is paid once, even when requests arrive together.

What I did. Sent five payments for the same transaction at the exact same moment. Then sent several create requests that all used the same ID at once.

What happened. Sent one at a time, it worked fine. The first payment went through, and the rest were rejected as duplicates. Sent all at once, it fell apart. I fired five payments at the same transaction at the same moment. Only one should have succeeded, and the other four should have been rejected. Instead, they all came back as “successful” at the same time. Creating a transaction went the same way: six requests that reused one ID, sent at once, created several separate records for that one ID.

Why it matters. “Paid once” is the rule that stops a payment going through twice. Here it only works when requests come one at a time. But firing a burst of them at the same instant is easy, and that is exactly what a race attack does. So the check that looks solid can be beaten on purpose. That is how one payment gets charged twice, or one transaction ends up with several conflicting records. A rule that breaks the moment two requests overlap is not really protecting anything.

Finding 3

No limit on how fast a caller can hit the API

The promise: one caller cannot send unlimited requests.

What I did. Sent a burst of requests to the same endpoint from a single credential.

What happened. Every request was accepted. No throttling, no lockout, and no “too many requests” response at any point.

Why it matters. On its own, this is a resource problem: a single credential can drive the API flat out, pile up unbounded pending transactions, and eat back-end capacity with junk records. The sharper problem is that it takes the cost out of every other weakness on this list. The transaction-killing bug in Finding 1 stops being a way to block one payment and becomes a way to block payments in bulk, on a script, for free. Guessing or enumerating transaction IDs is only viable because nothing slows the attempts down. A rate limit is the control that normally stops a single-request bug from scaling into a mass attack, and there was none, so every other finding here gets cheaper and larger.

Finding 4

The payment step could be skipped entirely

The promise: nothing is marked paid until the pay step is actually called.

What I did. On four billers, I checked the status right after the lookup step, before ever calling pay.

What happened. The transaction already came back as fully successful, on a payment I had not authorised. Calling the pay step afterwards changed nothing: it was already “paid” before the step that is supposed to make it so.

Why it matters. This is a broken authorisation control, not a harmless ordering quirk. The two-step flow exists precisely so that a payment is not final until an explicit pay call authorises it; that call is the gate between “a bill was looked up” and “this payment is authorised.” On these billers the gate enforces nothing, so anyone holding the ordinary credentials can produce transactions that report as fully paid without ever authorising a payment. Any system that trusts that status, a bank ledger, a merchant’s reconciliation, a receipt shown to a customer, would treat an unauthorised transaction as a completed one. The step that is meant to prove a payment was actually intended and authorised proves nothing.

Finding 5

Script markup was stored straight into payment records

The promise: attacker-controlled text is neutralised before it is stored.

What I did. Put HTML and script markup into free-text fields, such as a name, then completed the payment.

What happened. The markup was accepted, stored, and returned exactly as sent, with no escaping. It ended up bound into a completed payment record.

Why it matters. Any application that later shows this record in a web page (a receipt, a saved-payee list, a transaction history, an internal dashboard) and does not escape it will run the attacker’s script. The value comes back as JSON, so it does not execute at the API itself. The vulnerability is that the API stores active, attacker-controlled markup inside a financial record with no neutralisation, a stored cross-site-scripting sink waiting for a place to render.

Finding 6

Some billers accepted any amount, up to a billion

The promise: every amount is bounded by one shared check.

What I did. Sent very small and very large amounts to different billers.

What happened. Top-up billers enforced a sensible range. But the insurance, club, and ISP billers accepted anything, from a single unit up to nine digits, and settled it.

top-up biller     amount = 1,000,000,000   → rejected  range enforced
insurance biller  amount = 1,000,000,000   → SETTLED   accepted and paid
insurance biller  amount = 1               → SETTLED   no floor either

Why it matters. With no ceiling, one request settles an enormous charge, and nothing stops a mistake or an abuse. The fact that some billers enforce a range while others enforce nothing shows the check is duplicated per biller instead of applied once, so an attacker only has to find the biller where it was left off, and the next biller added inherits the same gap.

04 · The pattern

Four endpoints, six bugs

The whole test surface here was tiny: four endpoints. None of the six findings were exotic, though. Each one is just a promise the API makes, and one request that breaks it. That is the whole method. Map the payment as a state machine, list the promises it must keep, then try to make each one false.

On a payment API, this is where the effort pays off most. The workflow is where real money and real trust move, and it only gives up its bugs to someone who reads the flow closely and tests whether each step actually does what it claims.

The takeaway

Before you poke an API, map its state machine and write down the promises it must keep. Then go break them, one at a time. That list is your test plan.

On anonymisation. Every identifier here (the parties, the endpoint names, the biller categories, the status values, the amounts) is generalised or invented. This describes a class of system and a way of testing it, not any specific product, provider, or engagement. Nothing here reproduces client data.