Capping AI Spend per User: Denial of Wallet and a Practical Design
A bot check decides who gets in, not how much they spend. A source-checked look at denial of wallet, what OpenAI and Anthropic do and do not cap, and a per-user budget design with a reserve and settle sketch.
- Author
- Kamran Khan
- Published
- Reading time
- 18 min read
On this page
- An example: how one user costs nearly $900 an hour
- What OWASP says
- What the providers do and do not offer
- A design, in order
- Why reserve first
- The reserve and settle sketch
- The route, with output capped
- The burst limit
- The provider cap and alerts
- The order of defences
- What this does not protect
- What I could not verify
- Further reading
A bot check decides who gets in. It does not decide how much they spend. So a real, signed-in user can still drain your AI budget, and the fix I would reach for is a per-user budget in your own database, checked before the model runs.
This is the companion to a one-minute Short, and a sequel to my earlier one about bot signups draining free AI credits. That one was about Cloudflare Turnstile, which is a bot check, not an ID check. It asks whether a visitor looks like a person. It never asks what that person will do next. If the key itself sits in your frontend bundle, no budget helps until you move it: see the article on an AI API key in a frontend bundle.
My rule on this site is to separate what is documented from what I am guessing. Documented claims come from pages I read on 14 October 2026. Anything labelled "my advice" is my own reasoning, so test it before you trust it.
Transcript
The bouncer kept every bot out. So how does one real guest run up nearly nine hundred dollars in an hour? In this example, a script asks once a second, at twenty-five cents a reply. The door only asks if you're a person. It never asks how much you'll use. OWASP calls that denial of wallet: entry ten on their twenty twenty-five list. So give every guest a budget. In your own database. OpenAI and Anthropic don't offer one per end user. My advice, not a rule. Reserve the worst-case cost before the model runs, and say no if it won't fit. Cap reply length with max output tokens: two cents a reply, and half a dollar buys twenty-three. A provider's hard limit is the last resort. Anthropic's tier cap pauses your whole API until next month. Set a limit anyway. A budget won't stop a thousand fake guests: about five hundred dollars a day at most, in this example. So keep the door check, and a global cap. The bouncer picks the guest. The budget caps the bill.
Watch the Short on YouTube: https://youtube.com/shorts/TJVb-3N5__4.
An example: how one user costs nearly $900 an hour
All prices here are made up for the arithmetic. They are not any provider's real prices.
Assume $3 per million input tokens and $15 per million output tokens. Say each request sends a 2,000-token prompt, and nothing stops the reply growing to 16,000 tokens.
- Input: 2,000 tokens at $3 per million is $0.006.
- Output: 16,000 tokens at $15 per million is $0.240.
- One request: $0.246, so about a quarter of a dollar.
Now picture one signed-in user running a script that asks once a second. That is 3,600 requests in an hour, and 3,600 times $0.246 is $885.60. One account, one hour, no bot check involved. Turnstile did its job at signup and the bill still arrived.
A per-user rate limit can slow a script like this, but only if you set the numbers low enough, and I come back to that below. A rate limit counts requests, not money. A slower script that always asks for the longest reply still costs the same per request. The thing that bounds the bill is a budget.
Now add two caps. Limit output to 1,000 tokens per reply:
- Output: 1,000 tokens at $15 per million is $0.015.
- One request: $0.006 plus $0.015, which is $0.021.
Give each user a daily budget of $0.50. Divide: $0.50 / $0.021 is 23.8, so 23 replies fit. The worst case is 23 times $0.021, which is $0.483 per user per day.
What OWASP says
OWASP's current list is the 2025 edition, and the entry is LLM10:2025 Unbounded Consumption. It is tenth on the list. OWASP's page says the risk occurs when an LLM application "allows users to conduct excessive and uncontrolled inferences" (OWASP, LLM10:2025). It lists Denial of Wallet as the second common example of the risk, and as attack scenario four, describing it as exploiting the cost-per-use model.
You may see "LLM06:2026" on vendor blogs. I found no 2026 edition on OWASP's site, and on its 2025 list LLM06 is Excessive Agency, so I use only the 2025 name.
For mitigations, OWASP's page lists 15 strategies. The ones that matter here are rate limiting and user quotas, input size limits, timeouts and throttling, and logging, monitoring and anomaly detection. The page says what to do, not how, and the design below is my attempt at the how.
What the providers do and do not offer
Documented, as of 14 October 2026.
OpenAI. The rate-limits guide says rate limits are defined at the organisation level and at the project level, not user level. A spend alert is a notification only; the spend limits guide says alerts do not enforce a cap. OpenAI added hard spend limits for organisations and projects on 22 July 2026 (changelog). They are monthly: if you do not raise or remove a limit you have reached, the guide says it resets with the next monthly cycle. A hard limit returns HTTP 429 with organization_spend_limit_exceeded or project_spend_limit_exceeded. The guide also says enforcement is not instantaneous, so recorded spend can slightly exceed the limit. The guide describes no per-user level, so I would not plan around one.
Anthropic. Spend limits and rate limits are separate things. The tier caps are $500, $1,000 and $200,000 a month for the Start, Build and Scale tiers. The Custom tier has no monthly spend cap. Reaching a tier cap pauses API usage until 00:00 UTC on the first day of the next month, unless you ask for a higher limit sooner, and requests return HTTP 429 with enforced_spend_limit_reached. A limit you set yourself behaves differently: it returns HTTP 400 invalid_request_error, and the docs say you can raise or remove it to restore access sooner. Workspaces can have spend limits and alert thresholds too. The only workspace the docs describe as supporting per-user monthly spend limits is the Claude Code workspace.
Neither provider documents a per-end-user cap for an app you build on them. That is an absence in the documentation, and I cannot prove nothing exists behind a sales call, but do not design on the assumption it does.
Anthropic's rate-limit page also says there is no rate limit downside to a higher max_tokens, so it is a cost lever, not a throughput lever.
A design, in order
This section is my advice. The order is the point: each layer catches what the one before it lets through.
- A per-user daily budget in your own database. Key it by your user ID, not an IP address. OWASP lists user quotas as a mitigation, and since the providers don't offer one per end user, you keep the number yourself.
- Reserve the worst-case cost before the call, and settle after. Before the model runs, add the most this request could cost to the user's counter. If that would pass the budget, refuse. After the reply, correct the counter to the real cost, up or down. If you use prompt caching or paid tools, add those charges to the cost too.
- Cap output tokens on every call. Without a ceiling there is no worst case to reserve.
- Reject early. Reserve, using your input cap, before the vector store lookup or any other paid step. Cap input size too, as OWASP suggests, so the input half of the reservation has a bound.
- A per-user burst and hourly rate limit, so a script cannot spend the whole budget in a minute.
- The provider's hard limit as a last resort.
- Alerts below the cap, so you hear about it first.
Why reserve first
The tempting version reads how much the user has spent and refuses if it is over budget. With the example numbers that goes wrong twice. After 23 replies the user has spent $0.483, which is under $0.50, so the check passes, the 24th reply is let through and the day ends at $0.504. And if twenty requests arrive together, they can all read the same low number and all pass, so the overshoot can be many times a single request. Reserving the worst case first, with an atomic operation, closes both. The 24th reservation needs $0.504 of room, finds only $0.017 left, and is refused.
The reserve and settle sketch
This is a sketch, not production code. It uses Upstash Redis. I checked these points against the docs: INCRBY increments the value at a key and returns the new value, treats a missing key as 0, and works on 64-bit signed integers (Redis docs). Redis guarantees a Lua script's atomic execution (Redis scripting docs). Upstash's TypeScript redis.eval(script, keys, args) takes the script, then the list of keys, then the list of arguments. I did not run this against a live database, so test it before you rely on it.
I store money as integer millionths of a dollar. $3 per million input tokens is 3 units per token, and $15 per million output tokens is 15 units per token. The worst case in my example is 2,000 times 3 plus 1,000 times 15, which is 21,000 units. The daily budget is 500,000 units.
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
const DAILY_BUDGET = 500_000; // $0.50 in millionths of a dollar
// Add the reservation. If that passes the budget, undo it and return -1.
// The expiry is set first, so even a rejected call leaves no key without one.
const RESERVE = `
local used = redis.call('INCRBY', KEYS[1], ARGV[1])
redis.call('EXPIRE', KEYS[1], 172800)
if used > tonumber(ARGV[2]) then
redis.call('DECRBY', KEYS[1], ARGV[1])
return -1
end
return used
`;
// Move the counter from the reserved amount to the real amount.
// The delta can be negative (refund) or positive (the call cost more).
const SETTLE = `
local delta = tonumber(ARGV[2]) - tonumber(ARGV[1])
local v = redis.call('INCRBY', KEYS[1], delta)
redis.call('EXPIRE', KEYS[1], 172800)
return v
`;
// Compute the key once per request and pass it to both calls, so a request
// that crosses UTC midnight settles against the day it reserved on.
export const budgetKey = (userId: string) =>
`spend:${userId}:${new Date().toISOString().slice(0, 10)}`;
export async function reserve(key: string, worstCase: number) {
const used = await redis.eval(RESERVE, [key], [worstCase, DAILY_BUDGET]);
return Number(used) !== -1;
}
export async function settle(key: string, reserved: number, actual: number) {
await redis.eval(SETTLE, [key], [reserved, Math.round(actual)]);
}Settle works in both directions: a cheaper call gets the difference back, and a dearer one, because your estimate was low, has the extra added. A refund-only version quietly under-records exactly the requests that overspent.
If your process dies between reserve and settle, the reservation stays on the counter, which errs on the side of charging the user too much, not too little. That is the direction I want a cost cap to fail in. The counter resets with the date in the key, and the expiry cleans up old days. The date is UTC, so "daily" means a UTC day.
The route, with output capped
The parameter names differ by provider, and I checked both references. OpenAI's Responses API takes max_output_tokens, an upper bound on generated tokens that includes reasoning tokens, and returns usage.input_tokens and usage.output_tokens. Anthropic's Messages API takes max_tokens, which is required, and returns usage.input_tokens and usage.output_tokens.
import OpenAI from "openai";
import { budgetKey, reserve, settle } from "./budget";
const openai = new OpenAI();
const IN_PRICE = 3; // example prices, in millionths of a dollar per token
const OUT_PRICE = 15;
const MAX_OUT = 1_000;
const SYSTEM_PROMPT_TOKENS = 500; // size of your own fixed instructions
// 4xx statuses meaning the request was rejected. 408 and 409 are left out on purpose.
const REFUNDABLE = [400, 401, 403, 404, 413, 422, 429];
export async function POST(req: Request) {
// Your auth and body parsing, not shown. The body is untrusted.
const { userId, prompt } = await getSessionAndBody(req); // { userId: string; prompt: unknown }
// Check the type first, or an array or object could slip past the size cap.
if (typeof prompt !== "string") return new Response("Bad input", { status: 400 });
if (new TextEncoder().encode(prompt).length > 1_500) {
return new Response("Too long", { status: 413 }); // 1,500 + 500 = 2,000 input tokens at most
}
// Upper bound on input tokens: one token per UTF-8 byte, plus your own prompt.
const inputBound = new TextEncoder().encode(prompt).length + SYSTEM_PROMPT_TOKENS;
const worstCase = inputBound * IN_PRICE + MAX_OUT * OUT_PRICE;
const key = budgetKey(userId);
if (!(await reserve(key, worstCase))) {
return new Response("Daily budget used", { status: 429 });
}
let r;
try {
r = await openai.responses.create({
model: "your-model",
input: prompt,
max_output_tokens: MAX_OUT, // Anthropic's equivalent is max_tokens
});
} catch (e) {
// Refund only when the provider clearly rejected the request.
// On a timeout or an unknown error the call may still have been billed,
// so keep the reservation.
if (e instanceof OpenAI.APIError && REFUNDABLE.includes(e.status ?? 0)) {
await settle(key, worstCase, 0);
}
throw e;
}
const actual =
(r.usage?.input_tokens ?? 0) * IN_PRICE +
(r.usage?.output_tokens ?? 0) * OUT_PRICE;
await settle(key, worstCase, actual); // outside the try: log and alert if this throws
return Response.json({ text: r.output_text });
}Several choices in that sketch are mine, not documented ones. The 1,500-byte cap plus 500 tokens for my own instructions gives the 2,000-token input in the example, so the code reserves exactly the 21,000 units from the arithmetic. Counting one token per byte as the upper bound rests on my assumption that a byte-level tokenizer cannot emit more tokens than it reads bytes. I have not verified that for any model, so check it for yours, or use your provider's token-counting endpoint. Dividing characters by three is not safe: it under-counts non-English text, code and emoji. TextEncoder works on the Edge runtime, where Node's Buffer does not exist.
The refund rule is also my advice. I refund only 400, 401, 403, 404, 413, 422 and 429, where the provider clearly rejected the request. I leave out 408 and 409, which are a timeout and a conflict and which the SDKs retry, because I found no documentation on whether they are billed. Provider billing for failed or timed-out calls is not something I found documented at all, so I keep the reservation unless I know the request was rejected. Settle sits outside the try so that a Redis failure after a billed call cannot trigger a second refund.
The burst limit
The budget bounds the day, not the minute. Upstash's rate-limit library has a documented sliding-window limiter; the docs' example is 10 requests per 10 seconds, and a code comment suggests a userID, apiKey or IP address as the identifier. Here are two limiters. The numbers are mine:
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const redis = Redis.fromEnv();
const burst = new Ratelimit({ redis, prefix: "burst", limiter: Ratelimit.slidingWindow(3, "10 s") });
const hourly = new Ratelimit({ redis, prefix: "hourly", limiter: Ratelimit.slidingWindow(200, "1 h") });
// Inside your route handler, after you know userId. Check the burst first,
// so rejected bursts do not use up the hourly allowance.
if (!(await burst.limit(userId)).success || !(await hourly.limit(userId)).success) {
return new Response("Slow down", { status: 429 });
}The docs' own 10-per-10-seconds example would not stop the script in my worked example. That script asks once a second, which is exactly 10 requests per 10 seconds, so it would pass for ever. A window of 3 per 10 seconds does cut it off, and the hourly limiter caps a patient script that stays just under the burst limit. Pick your numbers from your real usage. The documented parts are the limiter and limit(identifier). Which numbers to use is my advice. Run these checks first, because they are cheaper than the budget check.
The provider cap and alerts
Set the provider's hard limit anyway. On the Start, Build and Scale tiers the Anthropic cap applies whether you want it or not, and it pauses your organisation's API until the first of the next month, for every user. A limit you set yourself on Anthropic returns 400 and can be raised, so it is a safer tripwire than the tier cap, and I would set one. The docs do not confirm when a self-set limit resets, only that the error message states when access resumes. On OpenAI, set a project hard limit, and remember it is monthly and can overshoot slightly.
Then add alerts below the cap, so you hear before the cap acts. OpenAI's alerts only notify, and Anthropic workspaces offer alert thresholds.
The order of defences
| Layer | What it stops | What it does not stop |
|---|---|---|
| Signup bot check (Turnstile) | Scripted signups | A real or signed-in user spending a lot |
| Max output tokens | One enormous reply | Many ordinary replies |
| Per-user burst and hourly limit | A burst faster than the window allows | A patient user who stays under the window all day |
| Reserve and settle budget | One user's daily total, including parallel requests | Many accounts, each with its own budget |
| Global cap and provider hard limit | Total spend for the whole app | One user starving the others, because it stops everyone |
| Alerts | Surprises you would otherwise miss | Anything, on their own: they only notify |
What this does not protect
A budget per user is only as good as your supply of users. If someone gets 1,000 fake accounts past signup, each can spend its full budget. In the example that is about $500 a day at most, which is 1,000 times the $0.50 budget. It would be $483 if every reply cost its full worst case, but settle refunds let accounts use more of the budget than that, so $500 is the honest ceiling. It also assumes every account spends everything, and that your input bound holds.
So keep signup protection, and keep a global cap. The signup check filters who comes in, the budget caps what each of them costs, and the global cap is what is left when both are beaten. It is blunt, because it pauses everyone.
Cloudflare's rate limiting is no substitute for the budget either; it counts by IP address on the Free and Pro plans.
What I could not verify
- The two Lua scripts and the route were tested locally against Redis 7 by an independent check (reserve, reject, settle up and down, refund, expiry, and 30 parallel reserves, of which exactly 23 were accepted). They were not tested against Upstash itself or against a live provider.
- I did not confirm when a limit you set yourself on Anthropic resets. The docs say the error message states when access resumes.
- I did not find a per-user spending control at OpenAI or Anthropic. That is an absence in the docs, not proof of absence.
- I did not find a vendor post-mortem of a surprise AI bill. The cases I found were forum threads and press stories about leaked keys, which is a different problem from signed-in users, so I do not cite any bill as fact.
- Billing for failed or timed-out calls is not covered by the pages I read, so my refund rule is a precaution, not a documented fact.
- That a byte-level tokenizer cannot produce more tokens than bytes is my assumption. Use your provider's token-counting endpoint if you need certainty.
- All prices are invented for the example. Use your provider's current price page.
Further reading
- OWASP, LLM10:2025 Unbounded Consumption
- OpenAI, spend limits
- OpenAI, rate limits
- OpenAI, Responses API reference
- OpenAI, reasoning guide
- Anthropic, rate limits and spend limits
- Anthropic, workspaces
- Anthropic, Messages API reference
- Redis, INCRBY
- Redis, EVAL
- Upstash, rate limiting for TypeScript
- Upstash, redis.eval
- Cloudflare, rate limiting rules
- My earlier Short on bot signups
Frequently asked questions
What is denial of wallet?
It is an attack, or an accident, where someone makes a pay-per-use service run up your bill instead of taking it offline. OWASP names it inside LLM10:2025 Unbounded Consumption and describes it as exploiting the cost-per-use model. The official risk name is Unbounded Consumption, so search for that when you read the guidance.
Does max output tokens include reasoning tokens?
On OpenAI, yes. The Responses API reference says max_output_tokens is an upper bound for tokens generated, including visible output and reasoning tokens. OpenAI's reasoning guide says reasoning tokens are billed as output tokens. So the parameter works as a cost cap for reasoning models as well.
What happens when I hit a provider spending limit?
On OpenAI, a hard limit returns HTTP 429 with the error codes organization_spend_limit_exceeded or project_spend_limit_exceeded, and the guide says enforcement is not instantaneous. On Anthropic, the tier cap returns HTTP 429 with enforced_spend_limit_reached, while a limit you set yourself returns HTTP 400 invalid_request_error. Organisations on Anthropic's Custom tier have no monthly spend cap.
Can Cloudflare rate limiting act as a per-user budget?
No. Cloudflare says its rate limiting is not designed to allow a precise number of requests to reach your origin, and on the Free and Pro plans the counting characteristic is the IP address only. It is a useful blunt layer, but a budget needs your own user ID and your own counter.
Why use integers for the budget?
Redis INCRBY works on 64-bit signed integers and sets a missing key to 0 first, so whole numbers are the safe choice, and my code rounds the settled cost for the same reason. Storing millionths of a dollar avoids floating-point drift. In my example, $0.50 is 500,000 units.
How often should the daily budget reset?
That is a product choice. I key the counter by user ID and UTC date and let it expire after a couple of days, so there is no reset job to fail. A rolling window is possible but harder to explain to users.