Unexpected OpenAI bill spike: how to find the cause and stop it
To stop an unexpected OpenAI bill spike, delete the API key that spends, set a hard spend limit on the project, and then find the route that lets other people use your key. The cause is one of three things: a leaked key, an open server route, or a bug in your own code. The usage page of OpenAI shows which one it is.
What does an OpenAI bill spike mean?
It means that your API usage is much higher than your users can explain. A normal app has a stable pattern. Cost rises when users rise. A spike breaks that pattern. The cost jumps in a few hours, or it stays high at night when your users sleep.
OpenAI bills by use. Each request has a cost that depends on the model, the input tokens, and the output tokens. The service does not know if the caller is a real user or an attacker. Any request with a valid key is a valid request.
Three causes explain most spikes in apps that vibe coding tools build:
| Cause | How it happens | Typical sign |
|---|---|---|
| Leaked key | The key is in the browser bundle, a public repository, or a log | Use of models that your app does not call |
| Open route | Your server route has no login check and no limit | Many requests from many IP addresses, same prompt pattern |
| Own bug | A loop, a retry, or a very long prompt | Regular bursts that match your deploy times or job schedule |
How do I find the cause of the spike?
Read the usage data first. Do not change anything yet, unless the cost is rising fast. If the cost rises fast, go to the next section and stop the spend, then come back.
- Open the usage page. In the OpenAI dashboard, go to Settings, then Organization, then Usage.
- Select the date range. Include the days before the spike, so that you have a baseline.
- Group by project. Find the project with the spike. A spike in a project that your app does not use points to a leaked key.
- Group by model. Find models that your app never calls. A model that you did not choose is a strong sign of abuse.
- Group by key, if the page shows it. Match the key name to the place where you use it. The production best practices guide states that keys created before December 20, 2023 have no usage tracking by default. Their use shows as “Untracked”.
- Look at the time pattern. Abuse often runs all day. A bug often matches a deploy or a schedule.
- Compare with your own logs. Compare the OpenAI request count with the request count of your server route. If OpenAI shows many more requests than your server logged, someone uses the key directly.
The last step is the most useful. Use this rule:
- OpenAI count is much higher than your server count: the key is leaked. Go to the leaked key fix.
- Both counts are high and match: someone uses your route. Go to the open route fix.
- Both counts match and the traffic is your own jobs: you have a bug. Go to the bug fix.
Note: The OpenAI usage page shows cost and volume. It does not show the content of the prompts. Your own server logs are the only source for prompt content, so keep them.
What do I do first? Stop the spend now
Do these steps in this order. Each step reduces the loss.
Caution: These steps can break the AI feature of your app. A broken feature costs less than an unlimited bill. Restore the feature after you fix the cause.
- Delete the key that spends. Open the API keys page in the dashboard and delete the key. A deleted key stops working at once. If you do not know which key spends, delete all keys that your app uses and create new ones later.
- Set a hard spend limit. In the project settings, select Limits. Under Spend, select Edit spend limit, enter a Monthly spend limit, and turn on Enforce a hard limit. The spend limits guide shows the same steps for an organization.
- Add a spend alert below the limit. The guide states that spend alerts send a notification and do not enforce a cap. Use an alert for a warning and the hard limit for the stop.
- Read the effect of the limit. After the project reaches the limit, affected requests return a
429error with the codeproject_spend_limit_exceeded. Your app must show a clear message to users when this happens. - Create a new key in a separate project. Give the key an expiry date. Store it only on the server.
- Find and fix the cause. Use the next sections.
Note: The spend limit is not instant. The guide states that recorded spend can slightly exceed the limit.
How do I fix a leaked key?
A leaked key is the most common cause when the usage page shows models that you do not use. Anyone who finds the key can use it from any place.
Check the places where the key can appear. Look in the browser bundle, in the repository history, in build logs, and in screenshots or chat messages that you shared.
The full procedure is in OpenAI API key exposed in the frontend. The short version:
- Delete the leaked key. You did this in the first step.
- Search the deployed JavaScript for
api.openai.comandBearer. - Search for variable names that start with
VITE_orNEXT_PUBLIC_and containOPENAI. - Move the OpenAI call to a server route.
- Deploy again and search the new bundle.
Also read environment variables security. It explains how build tools copy variables into the browser.
How do I fix an open server route?
An open route is a route on your own server that calls OpenAI for any caller. The key stays private, but your route is a free proxy. The attacker sends requests to your URL, and you pay.
Test your own route without a login. Use a terminal, not the browser, so that no cookie is sent:
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST https://your-app.example/api/ask \
-H "Content-Type: application/json" \
-d '{"prompt":"say hello"}'
If the result is 200, anyone can use the route. The expected result is 401.
Then check what the caller controls. Open the code of the route and answer these questions:
- Does the route accept a
modelfield from the caller? - Does the route accept
max_tokens,tools, ortemperaturefrom the caller? - Does the route accept a full
messagesarray, or only a prompt? - Is there any limit on the length of the input?
If the answer to any question is yes, the caller controls your cost. Accept only the user prompt from the browser. Set the model, the output limit, and all other parameters on the server.
Add these controls to the route:
| Control | What it stops |
|---|---|
| Login check | Anonymous callers |
| Fixed model | Calls to an expensive model |
| Input size limit | Very long prompts |
max_output_tokens |
Very long answers |
| Per-user daily budget | One user who sends many requests |
| Global daily budget | Many accounts that each send a few requests |
The API abuse protection guide has more patterns for rate limits.
How do I add a per-user budget?
A request count is a weak limit, because one request can be cheap or expensive. A token budget is better. The OpenAI response includes a usage object with token counts. Add the tokens to a counter after each call.
Use a table in your database:
create table public.ai_usage (
user_id uuid not null,
day date not null default current_date,
tokens bigint not null default 0,
primary key (user_id, day)
);
alter table public.ai_usage enable row level security;
Do not add a policy for the authenticated role. Only your server code, with a server-side key, writes to this table. With no policy, the browser cannot read or change the counters. Read the Supabase RLS guide for more on policies.
Before the call, read the counter. If the counter is above the limit, return 429. After the call, add the tokens:
const DAILY_LIMIT = 50_000;
const { data: row } = await admin
.from("ai_usage")
.select("tokens")
.eq("user_id", user.id)
.eq("day", today)
.maybeSingle();
if ((row?.tokens ?? 0) >= DAILY_LIMIT) {
return new Response("Daily limit reached", { status: 429 });
}
// call OpenAI with a fixed model and max_output_tokens, then:
await admin.rpc("add_ai_tokens", {
p_user: user.id,
p_tokens: result.usage.total_tokens,
});
The function add_ai_tokens is a small SQL function that does an upsert and adds the tokens. A single update statement avoids a race between two parallel requests. The check before the call is not atomic, so a user can go slightly over the limit. That is acceptable. The global limit catches the rest.
Add one global counter in the same way. Use a fixed row, for example a user_id of all zeros. If the global counter passes your daily budget, the route returns 429 for everyone. This caps your loss when an attacker uses many accounts.
How do I fix a bug in my own code?
A bug causes a spike when the code calls OpenAI more often than you expect. These cases are common in code that AI tools write:
- A retry loop with no limit. The code retries after each error and never stops.
- A call inside a render. A React effect calls the API on each render, and the render runs many times.
- A background job that repeats. A scheduled job runs again before the first run ends.
- A growing prompt. The code adds the full chat history to every request, so the cost of each message grows.
To find the bug, compare the time of the spike with your deploy history and your job schedule. Search your code for each place that calls OpenAI. For each place, ask: what triggers this call, and how often?
The fixes are simple. Limit retries to a small number and use a delay. Move calls out of render code. Add a lock to jobs. Trim the history to the last few messages.
The rate limits guide also states that rate limits exist at the organization and the project level, and not at the user level. Your own app must therefore track users. OpenAI will not do it for you.
How do I check the damage?
Check the money, the data, and the other secrets.
- Total the cost. Note the extra cost per day on the usage page. Keep a screenshot for a support request.
- Check your logs for prompt content. If an attacker used your route, your logs can show what the attacker asked. Look for prompts that are not about your product.
- Check other secrets. A key that leaks from a bundle can leave other secrets in the same bundle. Search for database keys, payment keys, and webhook secrets. If you find a Supabase
service_rolekey, read what to do about a leaked service_role key. - Ask for a review of the charges. Contact OpenAI support with the project ID, the dates, and the deleted key name.
A leaked OpenAI key does not give access to your user database. It does give an attacker your credit and your rate limits. Your real users can get 429 errors while the attacker uses the quota.
How do I prevent the next spike?
Use these controls for every app that calls a paid API.
- Keep keys on the server. Never use a public prefix for a secret.
- Use one project per app. Set a hard spend limit and an alert in each project.
- Set key expiry dates. The production best practices guide strongly recommends an expiry date for each project key.
- Require login for each AI route. Test the route with
curland no cookie after each release. - Fix the model and the output limit on the server. The browser sends only the prompt.
- Add a per-user budget and a global budget. Use tokens, not request counts.
When should I get help?
Get help in these cases:
- The extra charges are large and you did not cause them. Contact OpenAI support first, and give them the facts from the damage check.
- The usage shows no cause in your key, your route, or your code. Ask a security engineer to review the app.
- You find other leaked secrets in the same bundle or repository. Rotate each secret and check its own logs.
- Your logs show that an attacker sent harmful content through your app. You may have a duty to act on that content. Ask for legal advice.
After the fix, watch the usage page for three days. The daily cost must return to the baseline that you saw before the spike.
Common questions
Can I get a refund for charges that I did not cause?
Is a bill spike always an attack?
Does a hard spend limit stop the bill at the exact amount?
Which model should my route allow?
Do per-user limits help if the attacker creates many accounts?
Find open ai routes
A VibeEval scan calls the AI routes of your deployed app without a login and reports the routes that answer, because anyone can use these routes to spend your credit.
14-day free trial · No credit card · Cancel anytime