Skip to content

Model Denial of Wallet: Attacks That Cost You Money

5 min read · updated August 3, 2026

Classic denial of service takes your system down. Denial of wallet leaves it up, perfectly responsive, quietly generating an invoice you cannot pay. Autoscaling and usage-based pricing turned an availability attack into a financial one, and the defences are different.

The attack

The attacker’s goal is spend, not downtime. Every request is legitimate in form; there is no malformed input to reject and no flood to absorb. The system behaves exactly as designed, which is why monitoring built around error rates and latency sees nothing at all.

It is attractive because the asymmetry is extreme. A request costing the attacker nothing but a few hundred bytes can cost you a long-context call to an expensive model. OWASP folded this into LLM10, Unbounded Consumption, in the 2025 list, alongside the traditional resource-exhaustion cases.

The entry point is almost always a surface with no payment attached: the free tier, the unauthenticated demo, the public playground, the chat widget on a marketing page, the trial that only needs an email. Those are deliberately frictionless, which is the same as saying the attacker’s cost is zero while yours is per token. Anything reachable without a card needs a hard ceiling per identity and a global ceiling for the surface as a whole, and the global one is the important one — per-identity limits are exactly what a thousand cheap signups defeat.

How the bill actually runs

  • Volume. The obvious one, and the one rate limits address.
  • Input size. A single request with a very large context can cost more than thousands of ordinary ones. If your endpoint accepts documents, the size limit is a cost control.
  • Output size. Output tokens are typically several times the price of input. A request without a max_tokens bound is an open cheque, and a prompt that asks for maximum-length output is a one-line attack.
  • Expensive routes. If the caller can choose the model, they will choose the dearest. Reasoning modes multiply token counts by generating tokens you never see.
  • Agent loops. The highest-variance path by far. Injected content that induces a retry loop, two agents that call each other, or a task the agent cannot complete but keeps trying — every iteration is a full-context call, and cost grows with the square of the turn count as history accumulates.
  • Cache defeat. If you rely on prompt caching for economics, prepending a random string to every request removes the discount without changing anything else.
  • Retry amplification. Your own retry policy turns one expensive failure into three. Timeouts that fire after the provider has already generated the tokens bill you for work you discarded.

Caps that bind, alerts that inform

The distinction that matters: a cap changes what the system does; an alert changes what a human does, eventually, if they are awake. Both are worth having and only one of them is a control.

ControlDescription
hard spend capRequests are refused past a limit, per key and per account, on a window you choose. The only control that binds without human involvement. Requires deciding, in advance, that a refused request is better than an unbounded bill.
per-request boundsMaximum input size, max_tokens on every call, a timeout, and an allowed-model list. Cheap, static, and they close the single-expensive-request path that volume limits miss.
loop boundsMaximum agent iterations, maximum tool calls per task, maximum accumulated tokens per task. Fail the task loudly at the bound rather than continuing quietly.
budget alertsInformational. Useful at 50% and 80% of an expected daily spend, and useless at 3am unless someone is paged. Never the only control.
anomaly alertsSpend rate relative to that key's own baseline. Catches the slow version that a fixed threshold misses until the month ends.

A budget guard

// Estimate before the call, reconcile after. The estimate must use the
// WORST case (max_tokens), or a streaming response can overrun a budget
// that looked fine when the request was admitted.

type Limits = { perRequestUsd: number; perDayUsd: number; maxTurns: number };

export async function guarded<T>(
  ctx: { keyId: string; taskId: string; turn: number },
  req: Request,
  limits: Limits,
  call: (r: Request) => Promise<Usage & { value: T }>,
): Promise<T> {
  if (ctx.turn >= limits.maxTurns) {
    throw new BudgetError("turn_limit", { turn: ctx.turn });
  }

  const worst = priceOf(req.model, req.inputTokens, req.maxTokens);
  if (worst > limits.perRequestUsd) {
    throw new BudgetError("request_too_expensive", { worst });
  }

  const spentToday = await spend.get(ctx.keyId);
  if (spentToday + worst > limits.perDayUsd) {
    throw new BudgetError("daily_cap", { spentToday, worst });
  }

  // Reserve the worst case so concurrent requests cannot each pass the
  // check and collectively blow the cap.
  await spend.reserve(ctx.keyId, worst);
  try {
    const out = await call(req);
    await spend.settle(ctx.keyId, worst, priceOf(req.model, out.inputTokens, out.outputTokens));
    return out.value;
  } catch (e) {
    await spend.settle(ctx.keyId, worst, 0);
    throw e;
  }
}

The reservation is the part that is easy to omit and expensive to omit. A check-then-call design with no reservation lets a hundred concurrent requests each observe the same under-budget state, and the cap is then exceeded by a factor of a hundred at exactly the moment it mattered.

The self-inflicted majority

Be honest about where the money actually goes: most large unexpected bills are not attacks. They are a retry loop with no ceiling, a prompt-template change that quadrupled context size, a test suite pointed at production, a debug log that re-sent every request, or an agent that could not finish a task and kept going. The controls are identical, which is convenient — build them for the attacker and they will catch the deploy.

Two habits close most of it: make the cost of a change visible in code review by tracking tokens per operation as a metric, and set caps on non-production environments tighter than production, since staging is where the runaway loop is written.

One more piece of plumbing is worth the afternoon it takes. Bill the spend back to the feature that caused it, by tagging every call with a workload identifier, so that a graph of cost per feature exists before anyone needs it. Without that attribution, a doubled invoice is a week-long investigation across teams; with it, the offending path is obvious in a minute, and the same tags are what let you set a cap per feature rather than one number for the whole account.

Model Denial of Wallet: Attacks That Cost You Money · Multigrid