Denial of wallet: rate-limit your AI and paid endpoints

$ cat blog/denial-of-wallet-rate-limiting.md
✓ vibe coding
✓ AI-generated code
✓ rate limiting
✓ API security
10 min read
$ 

Most people picture an attack as an outage. Denial of wallet is quieter: the site stays up, every request succeeds, and at the end of the month the invoice is the incident. If an endpoint costs you money each time it runs, anyone who can call it in a loop controls your spending.

The endpoints that cost money per call are easy to list: LLM completions, image generation, transcription, SMS and OTP sending, transactional email, paid third-party APIs, and serverless functions or jobs that bill by compute time. Each is a faucet. This post is about putting a tap on it.

Why the bill is the target

An attacker doesn't need to find a clever bug. They need an endpoint that does something costly and doesn't ask who is calling or how often. The usual motives are boring: free access to your model, a way to make you pay for their traffic, or simple sabotage by exhausting a quota so real users get errors.

A related pattern is SMS pumping, also called SMS toll fraud. An app that texts one-time codes to any number a visitor types can be used to trigger large volumes of messages, and when some of those numbers earn revenue for whoever controls them, your SMS provider's charges become someone else's income. You don't need the mechanics to see the defence: an endpoint that sends a paid message to an arbitrary destination, for an anonymous caller, with no limit, is a spending tap.

Why vibe-coded apps are exposed

A prompt like "add an AI chat feature" gets you a working feature. The assistant optimises for the demo, and nobody asked for the guard rails. The same shape shows up again and again:

  • The endpoint is unauthenticated. It worked in the demo without a login, so it ships without one.
  • There is no per-user or per-IP limit. Nothing counts calls, so nothing can refuse them.
  • Input size is unbounded. A prompt of a few kilobytes and a prompt of a few megabytes cost very different amounts.
  • The client chooses the expensive parameters. The request body carries the model name and max_tokens, and the server forwards them.
  • The key sits somewhere reachable. If the provider key ends up in the browser, there's no endpoint to limit at all; see API keys in frontend bundles.

Here is a handler with most of these at once:

// app/api/chat/route.ts (vulnerable)
export async function POST(req: Request) {
  const { prompt, model, maxTokens } = await req.json();

  const result = await llm.complete({
    model,                 // client picks the priciest model
    max_tokens: maxTokens, // client picks 100000
    prompt,                // no length check
  });

  return Response.json({ text: result.text });
}

No session, no counter, no ceiling. Anyone who finds the URL can choose the most expensive model and the longest output, then repeat.

Defence 1: require authentication first

Every endpoint that spends money should know who is asking. Anonymous access to a paid feature should be a deliberate product decision with its own tight limits, not an accident of the demo. Authentication also gives you a stable key to count against, which is what the rest of the defences need. A shared IP is a weak identity (offices and mobile networks share addresses, and attackers rotate them); a user id is a strong one.

The check must also be on the server for the specific action. A "Pro" badge in the UI isn't an authorization check. This is the same class of problem as broken access control.

Defence 2: rate-limit per user and per IP

A rate limiter counts requests in a window and refuses the ones over the limit. The simplest correct version is a fixed window in Redis: one counter per key per window, incremented atomically.

// Minimal fixed-window limiter. "redis" is any client with incr/expire.
export async function allow(
  redis: { incr(k: string): Promise<number>; expire(k: string, s: number): Promise<unknown> },
  key: string,        // e.g. "chat:user:" + userId
  limit: number,      // max requests per window
  windowSec: number,
): Promise<boolean> {
  const bucket = Math.floor(Date.now() / 1000 / windowSec);
  const k = "rl:" + key + ":" + bucket;

  const count = await redis.incr(k); // atomic, so concurrent calls can't both slip under
  await redis.expire(k, windowSec * 2); // set on every call, not only when count === 1
  return count <= limit;
}

Two details matter. INCR is atomic, so a burst of parallel requests can't all read "9" and each decide they're under ten. And the TTL is set on every call, not only the first, so a crash between the two commands is repaired by the next request in that window. Since the window number is in the key, refreshing the TTL never stretches a window. To make the pair a single step, send both in a MULTI transaction.

A fixed window has a known weakness: a caller can use the full limit at the end of one window and again at the start of the next, briefly doubling the rate. For most cost-protection that's fine. If it isn't, a token bucket or sliding window smooths the edges; the idea is the same, only the bookkeeping is richer.

Use it with two keys, a user limit and an IP limit, and fail closed:

const user = await requireUser(req); // 401 if not signed in
const ip = clientIp(req);            // see the caution below about where this comes from

const okUser = await allow(redis, "chat:user:" + user.id, 20, 60);
const okIp = await allow(redis, "chat:ip:" + ip, 60, 60);
if (!okUser || !okIp) {
  return new Response("Too many requests", { status: 429, headers: { "Retry-After": "60" } });
}

A caution on the IP. Each proxy appends to x-forwarded-for, so its leftmost entries are whatever the client chose to send. Use the client address your host documents (the entry its own edge added, or a header it sets), never simply the first value, and treat the IP limit as a backstop rather than your main control.

cost requests per minute your chosen cap no limits: bill keeps climbing with limits: flat at the cap
A stylised illustration, not measured data. With no limit, cost keeps climbing with request rate; with a rate limit and quota, it flattens at a ceiling you chose.

Defence 3: the server decides model, size and output cap

The client should send the question and nothing else about how it's answered. Pick the model and output cap on the server and reject oversized input before it reaches the provider:

const MAX_PROMPT_CHARS = 4_000;

const { prompt } = await req.json();
if (typeof prompt !== "string" || prompt.length === 0 || prompt.length > MAX_PROMPT_CHARS) {
  return new Response("Invalid prompt", { status: 400 });
}

const result = await llm.complete({
  model: process.env.CHAT_MODEL!, // fixed server-side
  max_tokens: 500,                // fixed server-side
  prompt,
});

If you do offer a model choice, map a small allowlist of names you've priced ("fast", "smart") to real model ids on the server. Never pass a client string straight through. The same goes for conversation history: cap how many turns you replay, since earlier messages are sent, and billed as input, again on each call.

Defence 4: daily quotas in your database

A per-minute limit stops bursts. A slow, patient abuser stays under it all day, so add a budget that resets daily, stored somewhere durable:

// MongoDB example. Make sure today's row exists, then reserve a unit before spending.
const key = { userId: user.id, day: today() };
await db.usage.updateOne(key, { $setOnInsert: { used: 0 } }, { upsert: true });

const reserved = await db.usage.updateOne(
  { ...key, used: { $lt: DAILY_LIMIT } }, // only matches while under the limit
  { $inc: { used: 1 } },
);
if (reserved.modifiedCount === 0) {
  return new Response("Daily limit reached", { status: 429 });
}

The point is the conditional update: check and increment in one atomic step rather than read, compare, then write. A unique index on { userId, day } stops two simultaneous first requests creating duplicate rows; ignore the duplicate-key error one of them gets. Decide whether a failed provider call should refund the unit. Quotas by tokens or credits are more accurate than by request count, if you can measure them.

Defence 5: cache, and protect sends

Identical requests shouldn't cost twice. If many users ask the same public question or you regenerate the same image, key a cache on a hash of the normalised input and serve the stored result. Skip this where answers are personalised or private.

SMS, OTP and email sends need extra care because the destination is attacker-supplied:

  • Require a verified account, or a CAPTCHA or similar human check, before sending to a new number or address.
  • Limit sends per destination as well as per caller, so one number can't be hit repeatedly.
  • Limit sends per account per day, and cap resends.
  • Restrict the countries you send to if you only serve some.
  • Don't reveal in the response whether a number or email is registered.

Defence 6: limits outside your code

Application limits can have bugs; a spending cap enforced by the provider doesn't depend on your code being right.

  • Set usage limits and budget alerts in your provider's dashboard for every paid API that offers them, with alerts well below any hard limit.
  • Use separate keys per environment (and ideally per service), so a leaked or abused staging key can't drain production and you can revoke one without an outage.
  • Monitor cost and volume. Alert on a sudden jump in calls per user, tokens per request, or sends per hour, not just on the monthly total.
  • Build a kill switch. A feature flag or environment variable that turns the paid feature off in seconds is worth more than any dashboard at 3 a.m.

How to check your app

Walk through each endpoint that calls something billable:

  1. Can an unauthenticated caller reach it? If so, is that intentional, and is it limited?
  2. Is there a counter per user and per IP, and what happens at the limit (a 429, not a silent pass)?
  3. Does the request body choose the model, max_tokens, temperature, number of images or any other cost driver?
  4. Is there a maximum input length, and a maximum output length set on the server?
  5. Is there a daily quota stored in a database, with an atomic check?
  6. Do SMS, email and OTP sends require a verified user and cap sends per destination?
  7. Are spend limits and alerts configured at each provider, with separate keys per environment?
  8. Can you disable the feature in one step?

To get an AI tool to help, ask for it in the same prompt: "this endpoint calls a paid API; require a signed-in user, rate-limit per user and per IP, fix the model and max output tokens on the server, cap input length, and add a daily quota." Then read what it wrote, because "rate limited" in a comment isn't a rate limit.

How OpenRouting helps

OpenRouting doesn't enforce rate limits or watch your bill, and it isn't a runtime monitor. It reviews your code. The access-control agent flags endpoints that perform sensitive actions without an authorization check, which covers the "paid endpoint with no login" case. The AI and LLM safety agent reviews code that calls models for prompt injection and unsafe handling of model output. Each finding gets a verdict, a plain-English explanation and a suggested fix.

You can run this on every change with pull-request review, which reports findings in a PR's changed files and posts a single summary comment, so a new unauthenticated AI route gets looked at before it merges. Limits, quotas and provider budgets are still yours to set.

Questions

What is a denial of wallet attack?

It is an attack that runs up your bill instead of taking your site down. If an endpoint costs money each time it runs, such as LLM completions, SMS or transactional email, anyone who can call it in a loop controls your spending.

How do I rate limit an AI chat endpoint?

Require a signed-in user, then count requests per user and per IP in a window, for example with an atomic Redis INCR per key, and return a 429 over the limit. Fix the model and max output tokens on the server, cap input length, and add a daily quota stored in a database.

What is SMS pumping?

SMS pumping, also called SMS toll fraud, abuses an app that texts one-time codes to any number a visitor types, triggering large volumes of messages to numbers that earn revenue for whoever controls them. Defend by requiring a verified account or a human check and limiting sends per destination, per caller and per day.

Should the client choose the model and max_tokens for an LLM request?

No. The client should send the question and nothing else about how it's answered; the server picks the model and output cap and rejects oversized input. If you offer a choice, map a small allowlist of priced names to real model ids on the server.

Keep reading