Module 5 · 6 min read
Denial of wallet: cost-weighted limits and quota design
Why an economic attack needs an economic control, and how to build quotas that survive agentic fan-out.
An attacker scripts your free-tier chat endpoint to send maximum-length prompts that trigger chains of expensive tool calls, and runs your inference bill up overnight. Nothing is stolen and nothing goes down. This is denial of wallet: an availability attack denominated in money rather than in latency, and it is one of the few attack classes where the victim's own elasticity is the weapon. The classic wrong response is to autoscale the inference fleet so that legitimate users are not slowed down. That preserves availability by design while amplifying exactly the spend the attacker set out to inflict: you have automated your own loss.
Two other reflexes miss as well. A CAPTCHA on signup raises the cost of creating accounts, which is worth doing, but the attack needs only a handful of accounts to be profitable and account creation is not where the money is spent. Caching responses at the edge helps only when prompts repeat, and an attacker varies them trivially; worse, a naively keyed shared cache introduces the cross-tenant hazard covered later in this course.
Why request counting fails under fan-out
Consider an API that allows 100 requests per minute per IP address. It was a reasonable limit when a request meant one database query. Then an agentic feature ships and a single request fans out into dozens of model calls, retrieval queries and tool invocations, with an outer loop that may retry. Now the cheapest and most expensive permitted requests differ by three orders of magnitude, and the limit constrains the count of a quantity whose cost you no longer control. Lowering the limit to twenty per minute does not fix this: it throttles the cheap requests that were never the problem while still permitting twenty of the expensive ones.
The second defect is the key. IP addresses are not principals. Many legitimate users share one address behind corporate NAT or a mobile carrier, so the limit punishes them collectively, while an attacker rotates addresses cheaply and is barely inconvenienced. Limits must be anchored to an authenticated principal: a user, a tenant, an API key, a service identity. That is also the only key under which a budget means anything, because you cannot bill or suspend an address.
Cost-weighted, per principal
- Consumption measured in input tokens, output tokens, tool calls and sandbox seconds
- Keyed on an authenticated user, tenant or service identity
- Budget checked before each expensive step inside a run
- Explicit step, tool-call and wall-clock ceilings per agent run
- A hard cap with a defined behaviour at exhaustion
Requests per minute, per IP
- Counts a unit whose cost varies by orders of magnitude
- Punishes users behind shared addresses and barely inconveniences rotation
- Admits a request, then lets it consume without limit inside its own loop
- No notion of fan-out, so one request can be a thousand
- Lowering the number throttles cheap traffic and permits expensive traffic
So the replacement is cost-weighted limits per authenticated principal: consumption measured in input and output tokens, tool invocations, sandbox seconds and retrieval calls, deducted from a bucket that refills at the rate you are willing to fund. It is not that rate limiting is the wrong layer and a web application firewall should absorb this instead: a firewall sees requests, which is exactly the unit that has stopped being meaningful. Nor can the limit move client-side into the SDK, because the client is under the attacker's control and metering there is advice rather than enforcement.
# Cost-weighted quota policy, keyed on the authenticated principal
key: principal # never client IP alone
units:
input_tokens: 1
output_tokens: 3 # generation costs more than prefill
tool_call: 50
sandbox_second: 200
retrieval_call: 10
tiers:
free: { refill_per_minute: 2000, burst: 6000, hard_daily_cap: 200000 }
paid: { refill_per_minute: 40000, burst: 120000, hard_daily_cap: null } # metered: bounded by the hourly circuit breaker below
agent_limits:
max_steps_per_run: 25
max_tool_calls_per_run: 40
max_wallclock_seconds: 120
check_budget: before_each_step # not only at request admission
circuit_breakers:
downstream_spend_per_hour: hard_stop
burn_rate_anomaly: alert_and_throttle
new_principal_default: free_tier_capCheck yourself
No attacker is involved: a legitimate tenant's agent enters a retry loop after a tool starts returning errors, and burns a large amount of budget inside a single admitted request. Which control stops this?
A limit applied only at request admission cannot see what happens inside the run. Per-run ceilings on steps, tool calls and wall-clock time, with the budget re-checked before each expensive step, stop a loop whether it was caused by an attacker or by a failing dependency. Adding capacity or lengthening timeouts makes the loop more expensive, not less.
Three design details make the difference between a policy and a working control. Check the budget before each expensive step, not only when the request is admitted, because a single admitted request can otherwise consume an unbounded amount inside its own loop. Give agent runs explicit step, tool-call and wall-clock ceilings, since a looping agent is indistinguishable from an attack in its cost profile and both need to stop. And make new principals inherit a conservative default cap automatically, because the account created five minutes ago is the one the attacker is using.
Try it first
You ship a hard daily cap per principal. The attacker registers two hundred free accounts and stays under every individual cap. What did the design miss?
Per-principal caps bound one identity, not the aggregate. You also need a ceiling at the level above: per organisation, per payment instrument, per signup cohort and per service overall, plus friction and reputation checks on account creation so that identities are not free to mint. A global circuit breaker on total spend per hour is the crude control that saves you when the per-principal maths is exploited at scale, and new principals should inherit a conservative default cap rather than the standard free-tier allowance.