Security · Abuse prevention

The endpoint that sends an SMS to anyone who asks

A button that texts a download link to a phone number is a growth feature. It is also, unmodified, a free SMS gateway pointed at anyone's phone.

Why this endpoint is different

Most abuse thinking is about data — who can read what. This class of endpoint is about actions with a cost. It cannot require authentication, because the entire point is serving someone who doesn't have an account yet. And every call spends real money and sends a real message to a real person.

Left open, it's two attacks at once: drain the sender's SMS budget, and harass an arbitrary phone number by pointing an automated loop at it.

Rate limiting needs two keys, not one

The instinct is to limit per IP address. That stops the crudest script and nothing else, because addresses are cheap — a residential proxy pool gives an attacker thousands.

Limiting only per phone number is no better. It protects individuals from being spammed but does nothing about someone walking through a list of numbers, one message each.

Both keys, enforced independently:

// Per phone: protects the recipient from repeat messages.
// Per IP:    limits how many distinct numbers one source can reach.
const recent = await requests.countDocuments({
  'phone.countryCode': countryCode,
  'phone.phoneNumber': phoneNumber,
  createdAt: { $gte: since(WINDOW) },
});
if (recent >= PER_PHONE_LIMIT) throw new TooManyRequestsError();

const fromIp = await requests.countDocuments({
  requestIp: ip,
  createdAt: { $gte: since(WINDOW) },
});
if (fromIp >= PER_IP_LIMIT) throw new TooManyRequestsError();

One rate-limit key always has a cheap bypass. Two keys, chosen so that evading one means paying for the other, is the smallest arrangement that actually costs an attacker something.

You can't enforce what you don't record

Rate limiting is a query over history, so the history has to exist and be fast to query. That means a dedicated collection recording phone, source address and timestamp — and compound indexes matching exactly the two queries above:

{ 'phone.countryCode': 1, 'phone.phoneNumber': 1, createdAt: -1 }
{ requestIp: 1, createdAt: -1 }

Without those indexes the rate limiter is a collection scan on every request — which turns your abuse protection into the thing that falls over first under abuse. That's not a hypothetical: a protection mechanism that degrades under load is worse than none, because it fails at exactly the moment it's needed.

A challenge for the automated case

Rate limits shape volume; they don't distinguish a person from a script. A CAPTCHA-style challenge does, cheaply, and it's the same tool that belongs in front of one-time-password flows — the other unauthenticated endpoint with a real cost per call.

It doesn't identify anyone. It just makes automation expensive enough not to be worth it, which for this class of abuse is the whole goal.

The bit that's easy to get wrong

Rate-limit responses shouldn't leak. Returning a different message for "this number has already been sent a link" versus "too many requests from your address" tells an attacker which limit they hit and therefore how to route around it. One response, one status code, no detail.

Same principle as returning 404 instead of 403 for an object someone doesn't own: the error is part of the attack surface.

← All engineering notes