ASO Ltd.

Blog

Every SMS code costs money. Stop strangers from spending it

If your app signs people in with a code by SMS, anyone who can reach the send button can make you pay for text messages. These are the limits we now build into every application, and what our own review caught when we did.

· 6 min read

If your app signs people in with a one-time code by SMS, every tap on "Send code" is a text message you pay for. The button is public by design: it has to work for someone who is not signed in yet. So anyone, or any script, can press it as often as your app allows.

It is natural to think about the security of the code itself. It is easy to forget to ask who can make you send it.

Why this is a cost problem before it is a security problem

Each code is a paid message. When we priced SMS for our delivery app, an international SMS provider quoted about $0.32 per message to a Cameroonian number, and a local operator bundle came to 22 FCFA a message. At the international rate, a script that triggers 10,000 codes overnight would cost more than $3,000. Nobody needs to break into anything for that to happen. They only need to find the button.

An open send button gets abused in a few predictable ways:

  • Bombing one person. Hundreds of codes to one number, as harassment, paid for by you.
  • Draining your capacity. If you send from a prepaid bundle or a rate-limited sender, a flood of fake requests uses it up, and real customers stop receiving codes.
  • Premium-number fraud. Codes sent to numbers the attacker earns money from. You pay, they collect.

There is a fourth cost that does not show on an invoice. If the button answers differently for a number that has an account than for one that does not, it becomes a way to check who your customers are.

The limits we now apply to every application

In September we wrote these down as a standard for every application ASO builds, starting with our own delivery app. They are deliberately boring.

  1. Count twice: per IP address and per phone number. A limit per IP address alone is beaten by rotating addresses. A limit per number alone lets one machine spray thousands of numbers. Normalise the number before counting, so 6 70 00 00 00, 237670000000 and +237 670 000 000 share one count, and count numbers that have no account too. Our starting point is 3 codes per number and 5 per IP address every 10 minutes, refused with HTTP 429 and a Retry-After header.
  2. Give the same answer whether or not an account exists. Same message, same status code. For a code request that means: "If an account exists for this number, we have sent it a text message with a code." Wrong, expired and already-used codes share one error message, so that error cannot be used to probe either.
  3. Make the response time boring. The same words are not enough if a real account takes longer to answer. Our own review found exactly that in our password recovery: a known account triggered a lookup and a send before the reply, so it answered more slowly than an unknown one. The fix was to answer every request that passes the rate limits after the same fixed time, and do the work in the background.
  4. Put a ceiling on the background work. Fixing the timing created a new problem, and the next review round caught it. Requests were now answered on time, but the work behind them could queue without limit. In a test that held the lookups up, three bursts of eight requests left 4, then 12, then 20 jobs waiting. The fix was a fixed number of slots for work running and waiting together, each released only when its job actually finishes. When every slot is taken, the request gets a polite "try again in a minute", decided before anything is looked up, so a full queue reveals nothing either.
  5. Let the app help, but let the server decide. The app holds "Send another code" shut for 60 seconds, with a visible countdown. Review found that going back to change the number, entering the same number again and pressing send skipped that countdown. The server's per-number limit still capped how many codes could go out, which is the point: rules in the app are for honest customers, and limits on the server are the defence.

Close the side door

Apps built on hosted sign-in services often ship a public key inside the app. Anyone can take that key and call the sign-in service directly, skipping every limit on your own server. So the provider needs its own settings, chosen rather than left at the defaults: public sign-up off when your server creates accounts, the provider's own limits on messages per hour set deliberately, and SMS restricted to the countries you actually serve. That last one is one of the cheapest protections against premium-number fraud you can switch on.

How to know the limits are really there

Write tests that can fail. One should list every route that sends a code or creates an account, and fail the day someone adds a new route without its limits. Another should prove that the request after the limit is refused, including for a number that has no account. Then break the fix on purpose and watch the test fail. A test you have never seen fail has not proven anything yet.

That last habit is how the problems above were found. Every finding came from a reviewer trying to break the code, not from the tests we wrote first.

What this does not solve

Being honest about the limits matters as much as the limits.

  • Our counters live in the memory of each application process. Two worker processes count separately, even on the same server, and a restart resets them. That is fine with a single worker, and it needs a shared store before we add workers or servers.
  • These limits slow a determined attacker with thousands of addresses and numbers. They do not stop one. CAPTCHA and operator-level caps are the next layer.
  • In our implementation, every password recovery request that passes the rate limits takes a full second, for everyone. That is the price of not revealing who has an account, and we think it is worth paying.
  • We are bringing our own products up to this standard as we work on them, and none of it counts as done until an independent review has tried to break it. We would rather publish what that review found than claim we got it right first time.

If your product sends codes by SMS, check two things this week: does the send button have a limit per phone number, and does it answer the same way for a number with no account behind it?