Your app has a route that talks to an LLM — /api/chat, /api/generate, something. It works: the user types, your server calls OpenAI (or Anthropic) with your server-side key, the answer comes back. Ship it.
Here's the part the demo never shows: that route uses your API key, and if it doesn't check who's calling, anyone can call it. Not through your UI — straight at the endpoint, in a loop. Every request spends your money, at your provider, on your key. People find these routes (they're in your client bundle's network calls) and point scripts at them, either to run up a bill or to get a free LLM to power their own app on your dime.
This is the money path pointed the other way: not "did they pay you," but "who is spending your money." The check is one line, and most AI-built apps skip it:
No request reaches your paid provider until you've confirmed the caller is signed in, allowed, and under their limit.
Where this bug lives
It's the proxy route almost every AI app has. The dangerous shape:
// vulnerable: your key, anyone's request, no ceiling
export async function POST(req) {
const { prompt, model } = await req.json();
const completion = await openai.chat.completions.create({
model: model ?? "gpt-4o", // client can even pick the expensive model
messages: [{ role: "user", content: prompt }],
});
return Response.json({ text: completion.choices[0].message.content });
}
Three separate holes, each of which costs you:
- No authentication. An anonymous request gets a completion. A stranger's traffic bills your key.
- No per-user limit. Even a real, logged-in user can loop the endpoint a thousand times a minute. One abusive account drains the same account you pay from.
- No ceiling on the request. The client picks the model and, if you let it, the length.
model: "gpt-4o"and an enormous prompt cost many times what your UI ever sends.
None of these break the demo. All of them show up on the invoice.
The fix: gate, meter, and cap — in that order
The caller has to prove who they are, you decide if they're allowed, and you decide how big the request can be:
// fixed: auth -> quota -> bounded request, before any paid call
export async function POST(req) {
const user = await getUser(req);
if (!user) return new Response("Unauthorized", { status: 401 });
const ok = await consumeQuota(user.id); // per-user rate + usage cap
if (!ok) return new Response("Rate limit exceeded", { status: 429 });
const { prompt } = await req.json(); // note: no client-supplied model
if (typeof prompt !== "string" || prompt.length > 4000) {
return new Response("Bad request", { status: 400 });
}
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini", // the server chooses the model
max_tokens: 512, // and caps the output
messages: [{ role: "user", content: prompt }],
});
return Response.json({ text: completion.choices[0].message.content });
}
Two things that trip people up. First, the per-user counter has to live in durable, shared storage (a Redis-style store, your database) — an in-memory counter isn't shared across the concurrent serverless instances your function runs on (and is lost on cold starts and instance recycling), so it can't enforce a per-user limit at all. Second, this is a backstop, not the whole story: also set a spend cap at the provider. Anthropic enforces a hard monthly cap; OpenAI offers budget limits and alerts, with prepaid credits and auto-recharge turned off as the firmest stop. Your code stops the ordinary abuse; the provider cap is the ceiling on a bill you didn't see coming while you were asleep. (One SDK note: max_tokens still works on gpt-4o-mini; newer o-series reasoning models want max_completion_tokens instead.)
Reproduce it with a test
You're not attacking anything — you're calling your own endpoint the way an outsider would, and watching whether it spends money:
- Anonymous call. Hit the route with no session cookie / no auth header at all. Passing:
401, and no call reached your provider. Failing: you get a completion — your key just paid for an anonymous request. - Over the limit. As a valid signed-in user, call it in a tight loop past your intended cap. Passing:
429once the quota is spent. Failing: it keeps answering, so one account can run your bill up with no ceiling. - Oversized prompt, and model override — separately. Send a 100 KB prompt: passing is rejected (
400, or413if you prefer) with no provider call. Then, with a normal prompt, send{"model": "gpt-4o"}in the body: passing is that the server ignores it and calls the provider with its own model (gpt-4o-mini), never the client's. Failing either way means the client just chose how much of your money to spend per call.
Assert on two things: the HTTP status and what actually reached the provider. Point the test at a stubbed provider client. For the anonymous and oversized cases, assert it was never called. For the model-override case, assert it was called — but with the server's gpt-4o-mini, not the client's gpt-4o. A route that returns 401 but called OpenAI first has already lost.
What a passing test proves — and does not
A green test proves this one route rejects anonymous and over-limit callers before spending, and ignores a client-chosen model. It does not prove your other AI routes do (each proxy endpoint needs the same gate), that your quota numbers are set sanely, or that your provider-side spend cap is configured. It doesn't cover prompt-injection or content abuse — a signed-in user sending hostile prompts is a different teardown.
The goal isn't to call the endpoint "secure." It's to hold one line you can falsify:
No unauthenticated, over-limit, or oversized request reaches the paid provider — and there's a hard spend cap at the provider behind it.
Re-run it whenever you add an AI route, change plans or limits, or swap providers. The feature will keep working in the demo; the test is what keeps a stranger's traffic off your invoice.
See the same red/green testing pattern in the reference repo
Run the broader launch-readiness checklist
Sources: OpenAI production best practices (managing costs) and Anthropic usage & cost controls (spend limits).
Test this on your own app before you sell it.