---
title: "No cap on LLM API spend"
slug: unbounded-llm-spend
severity: critical
category: AI
summary: "A model route with your key and no limit is a meter anyone can run. One script can spend a month of budget in an afternoon."
platforms:
  - Next.js
  - Vercel
updated: "2026-10-13"
related:
  - secret-keys-in-the-frontend
  - no-rate-limiting
  - prompt-injection
  - auth-checked-only-in-the-ui
---

Model providers charge by the token. If your Route Handler calls the provider whenever a POST arrives, the caller decides your bill. The failure is worse when the provider key is in the browser, because then they do not even need your route. See [secret keys in the frontend](/issues/secret-keys-in-the-frontend).

Two caps belong in place before you share the URL.

An application cap: the user must be signed in, each request has a max token budget, and each user has a daily count stored in your database. When they hit it, you return 429 and you do not call the provider.

A provider cap: in that vendor's own billing dashboard, set a monthly spend limit and an alert. This page does not invent the menu names. Open the billing or limits page for the account that owns the key and set whatever hard limit they offer. If a bug skips your app cap, the vendor limit is what stops the card.

A button that disables while loading is not a cap.

## How to check in 5 minutes

1. Find the route that calls the model. Confirm the API key is a server environment variable. Search the built JavaScript to be sure it is absent.
2. Confirm the handler checks a session before the provider call. Then look for a per-user counter. If there is none, there is no app cap.
3. Look for `max_tokens` or the provider's equivalent on the request. An unset output limit lets one call run as long as the model allows.
4. Log in to the provider dashboard and see if a spend limit is set. If you cannot find one, treat it as unset until you do.

## How to fix

### Next.js

```ts
const DAILY_LIMIT = 20;

export async function POST(request: Request) {
  const user = await getCurrentUser();
  if (!user) return new Response("Unauthorized", { status: 401 });

  const used = await countToday(user.id);
  if (used >= DAILY_LIMIT) return new Response("Daily limit reached", { status: 429 });

  await incrementToday(user.id);
  // call the provider with a max output token setting you chose
}
```

Pick `DAILY_LIMIT` by dividing what you can afford by how many users you might have, not by copying 20. Increment before the call so a crash still counts, or decrement if you prefer to count only successes. Either way, store the count in the database, not in memory on the serverless instance and not in the browser.

Streaming does not change the rule. A stream is still a paid call.

### Vercel

Function duration limits stop a hung request. They do not cap a thousand short requests. Keep the counter in a database or Redis that all instances share.

### Supabase

A `usage` table with RLS so users can read their own count and cannot update it is a reasonable store. The server, using a checked session, writes the count. Do not let the client POST `{ used: 0 }`.

### Stripe

If you resell model calls, the same route must not trust a client-supplied "I have credits" flag. Decrement credits on the server. See [the client sets the price or role](/issues/client-sets-price-or-role).

## Agent prompt

```text
You are capping model API spend. Do not add features.

1. Find every call to an LLM provider. Move any key in client code to a server environment variable and tell me to rotate the old key.
2. Require a signed-in user before each call.
3. Set a maximum output token budget on the provider request.
4. Add a per-user daily counter in the database. When the count is at the limit, return 429 and do not call the provider. Name the limit as a constant and pick a small starting number I can edit.
5. Do not store the counter in localStorage or in a module-level variable.

In your summary, remind me to set a monthly spend limit in the provider's billing dashboard. Do not claim you did that from code.
```

## FAQ

### We have a free trial with credits. Can this wait?

The trial ending in a surprise invoice is the story this page is about. Set the provider limit before you post the link, including on a free trial if the dashboard lets you.

### Is authentication enough?

No. A signed-in user, or a bot that created an account, can still loop the route. You want auth and a quota.

## Sources

- [Supabase API keys](https://supabase.com/docs/guides/api/api-keys)
- [OWASP Top 10 for LLM Applications](https://owasp.org/www-project-top-10-for-large-language-model-applications/)
