# SMS send routes are rate-limited

- **Pattern:** `ab-000243` (`ai-slop-cost-bombs.unrate-limited-spend.sms-rate-limited`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-000243
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-000243)

## Why it matters

SMS is the most expensive metered communication channel in a typical web stack — Twilio charges $0.0079–$0.02 per outbound message, and there is no free tier for abuse. An SMS route without per-IP rate limiting lets an attacker trigger thousands of messages per minute, reaching four-digit charges before any alert fires. CWE-799 (Improper Control of Interaction Frequency) is the direct mapping, alongside OWASP A05:2021. Beyond direct billing, carriers flag high-volume spammy senders for grey-listing or number suspension, destroying your transactional SMS deliverability for legitimate users. This is a cost bomb that also breaks a security-critical channel (OTP delivery).

## Severity rationale

High because SMS costs are metered per message with no platform-level cap, making a missing rate limit an immediately exploitable financial attack vector.

## Remediation

Add per-IP rate limiting at the route level before the Twilio client is called. Three sends per hour per IP is generous for legitimate OTP flows and stops abuse cold.

```ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import twilio from 'twilio'

const limiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.fixedWindow(3, '1 h'),
})

const client = twilio(process.env.TWILIO_SID!, process.env.TWILIO_AUTH_TOKEN!)

export async function POST(req: Request) {
  const ip = req.headers.get('x-forwarded-for') ?? 'anon'
  const { success } = await limiter.limit(`sms:${ip}`)
  if (!success) return Response.json({ error: 'Rate limited' }, { status: 429 })
  const { to, body } = SMSSchema.parse(await req.json())
  await client.messages.create({ to, from: process.env.TWILIO_FROM!, body })
  return Response.json({ sent: true })
}
```

## Detection

- **ID:** `sms-rate-limited`
- **Severity:** `high`
- **What to look for:** When an SMS library is in `package.json` dependencies (`twilio`, `@aws-sdk/client-sns`, `messagebird`, `vonage`, `@plivo/server-sdk`, `bandwidth-rest-api`), walk source files for SMS send call sites: `.messages.create(`, `.sendMessage(`, `client.messages.create(`. Count all SMS send call sites and verify each is preceded by a rate-limit check OR is inside a route handler that imports a rate limiter.
- **Pass criteria:** 100% of SMS send call sites are either gated by a rate limiter OR are part of a transactional flow (1 SMS per user action). Report: "X SMS send call sites inspected, Y rate-controlled, 0 unbounded."
- **Fail criteria:** At least 1 SMS send call has no upstream rate limiter.
- **Skip (N/A) when:** No SMS library in dependencies.
- **Detail on fail:** `"1 unbounded SMS handler: app/api/sms/route.ts calls twilio.messages.create() with no rate limit — at $0.01/SMS, an attacker can run up $1,000+ in minutes"`
- **Remediation:** SMS is even more expensive than email. A small rate limit per IP is mandatory:

  ```ts
  import { Ratelimit } from '@upstash/ratelimit'
  import { Redis } from '@upstash/redis'

  const limiter = new Ratelimit({
    redis: Redis.fromEnv(),
    limiter: Ratelimit.fixedWindow(3, '1 h'),  // 3 SMS per hour per IP
  })

  export async function POST(req: Request) {
    const ip = req.headers.get('x-forwarded-for') ?? 'anon'
    const { success } = await limiter.limit(ip)
    if (!success) return Response.json({ error: 'Rate limited' }, { status: 429 })
    await twilio.messages.create({ to: ..., from: ..., body: ... })
    return Response.json({ sent: true })
  }
  ```

## External references

- cwe CWE-770
- cwe CWE-799
- owasp A05

Taxons: cost-efficiency, injection-and-input-trust

HTML version: https://auditbuffet.com/patterns/ab-000243
