SYS://FIELD-REPORT-03

One Script Ran Your AI Endpoint All Night

The app worked. It looked finished. The demo was clean, the streaming responses felt fast, and the OpenAI key was tucked safely on the server where it belonged. And it was one for loop away from a four-figure invoice landing before the owner woke up — because nothing capped how many times a stranger could call the endpoint that spends money.

This is a representative audit — the exact pattern I run into over and over on apps built with v0, Lovable, Bolt, and Cursor. Names and paths are changed, but every finding below is real-shaped and drawn straight from the kind of report the launchworthy skill produces. Call the app draftpilot: a Next.js App Router app built with v0, a thin, good-looking wrapper that proxies OpenAI to turn rough notes into polished copy. Pre-launch, about to go up on Product Hunt.

It got a scorecard. The scorecard was not kind.

0/5
domains passing
1
critical finding
3
high findings

Zero domains passing doesn’t mean draftpilot is a bad app. It’s a nice app. It means it’s finished-looking, which is the exact state in which people hit deploy and post the link — and the exact state in which the most expensive bug hides, because on your own machine, calling your own endpoint a handful of times, nothing looks wrong at all.

The finding that ends the app: no rate limit on a paid endpoint

Here’s the honest, uncomfortable part, and it’s the whole reason this post exists. The finding that will hurt draftpilot most is not tagged CRITICAL. Per the checklist, no rate limit on an endpoint that calls a paid API is [HIGH], not [CRITICAL]. I’m not going to inflate it for drama. But “HIGH” is a severity, not a price tag — and this is the one that empties the bank account.

app/api/generate/route.ts:8 did exactly what v0 generates: it read the request body, called OpenAI, and streamed the answer back. No getCurrentUser. No limiter. No ceiling of any kind.

// app/api/generate/route.ts
export async function POST(req: Request) {
  const { prompt } = await req.json();
  const completion = await openai.chat.completions.create({
    model: "gpt-4o",
    messages: [{ role: "user", content: prompt }],
  });
  return Response.json(completion.choices[0].message);
}

The route is public. Anyone who opens the network tab sees the path and the shape of the payload in about ten seconds. From there it’s one line of curl in a while loop. Rate limiting is not about scale — it is about abuse and cost. One script pointed at this route overnight isn’t a performance problem. It’s a real bill, billed to a real card, for tokens a real stranger spent.

CRITICAL · CAUGHT

No rate limit on the OpenAI proxy route

The endpoint that spends money is openly callable and uncapped. One abusive user — or one runaway loop — runs gpt-4o in a tight loop until your card declines. You find out from a billing alert at 3 a.m., or from the invoice, whichever comes first. Tagged [HIGH] by severity; this is the finding that costs the most.

The fix is not exotic, and it does not require a rewrite. A limiter that works on serverless, keyed by user id if you have one and IP if you don’t, run before the expensive call.

THE FIX · 20 MIN

Add an Upstash rate limiter and gate the route on it. npm install @upstash/ratelimit @upstash/redis, put the two Redis credentials in server-side env (never a NEXT_PUBLIC_ prefix), and check the limit before you ever touch OpenAI.

THE FIX · IN ORDER

Cap the route before it spends

  1. Create the limiter in lib/ratelimit.tsRatelimit.slidingWindow(5, "60 s") is a sane start for an AI route.
  2. Call it at the top of route.ts, keyed by user?.id ?? req.headers.get("x-forwarded-for") ?? "anonymous", and return a 429 when it trips.
  3. Add auth to the proxy while you’re in there — an unauthenticated key means you can only rate-limit by IP, and IPs are cheap to rotate.
// app/api/generate/route.ts
import { ratelimit } from "@/lib/ratelimit";

export async function POST(req: Request) {
  const user = await getCurrentUser(req);
  const key = user?.id ?? req.headers.get("x-forwarded-for") ?? "anonymous";

  const { success, reset, remaining } = await ratelimit.limit(key);
  if (!success) {
    return new Response("Too many requests", {
      status: 429,
      headers: { "Retry-After": String(Math.ceil((reset - Date.now()) / 1000)) },
    });
  }

  // ...only now do the expensive OpenAI call
}

Verify it by hand Hit the endpoint in a loop past the limit — for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code}\n" -X POST .../api/generate -d '{"prompt":"hi"}'; done. You want to see 200s turn into 429s with a Retry-After header, and you want the limiter to fire before the OpenAI call, not after. If your token dashboard still ticks up on a blocked request, the check is in the wrong place.

The false alarm: the scary threat isn’t the expensive one

Now the credibility move, and the reason to trust an audit that gets this right.

Point a generic “review my AI app for security” session at draftpilot and it will reach, almost every time, for the frightening word: prompt injection. Jailbreaks. “A user could craft a prompt that makes your model ignore its instructions!” It sounds severe. It gets the attention.

FALSE POSITIVE

⚠️ Your endpoint is vulnerable to prompt injection and jailbreaks!

For a thin proxy that returns text to the same user who sent the prompt, this is mostly noise. There’s no privileged tool the model can be tricked into calling, no other user’s data in the context, no secret in the system prompt worth stealing. The worst a “jailbreak” gets you here is off-brand text in your own session. Meanwhile the real risk is far dumber and far more expensive: the route is uncapped and openly callable, and every prompt — adversarial or not — costs you money. Injection is a threat-model conversation for agents with tools. The four-figure bill is a billing reality tonight.

The distinction is the whole game. The scary-sounding threat and the expensive one are not the same threat, and a reviewer that pattern-matches on “AI security = prompt injection” burns your attention on the cinematic risk while the checkout counter runs unattended two lines away.

The rest of the punch list

The rate limit is the blocker. The rest is the same money leak from three other angles, ranked so the scope stays honest:

FIX THIS WEEK

After the rate limit

  1. [HIGH] Heavy AI call done inline in the request with no queue. Fine at demo volume; at real traffic it ties up a worker per request and turns a spike into a timeout cascade. Move long generations to a background job.
  2. [MEDIUM] No handling of an HTTP 429 from OpenAI itself. When the upstream rate-limits you, route.ts throws an unhandled 500 and the user sees a white error instead of a retry. Catch it, respect Retry-After, back off.
  3. [MEDIUM] Identical AI calls repeated with no caching — and it’s expensive. The same prompt bought the same completion twice; cache on a hash of the prompt and stop paying for answers you already have.

Four findings that matter, and notice the shape: one can drain the account tonight, three are the difference between “survives launch day” and “survives a good launch day.” That ranking is the point. A flat list reads as four equal chores. Worst-first reads as “cap the route before you post the link, then breathe.”

ASK YOURSELF

“Rate limiting is premature optimization.” Rate limiting is not about scale, it is about abuse. One script hitting your AI endpoint overnight is a four-figure bill, not a performance problem. On paid and auth endpoints it is a cost and security control, and it belongs in v1.

Why this keeps happening

None of this means v0 is bad, or that draftpilot’s owner did anything foolish. The tools are extraordinary at getting you to looks finished. They generate the route that calls OpenAI and returns the answer, because that’s the demo, and the demo works. They are silent about the limiter, because the limiter never shows up when the only person calling the endpoint is you, logged in as yourself, clicking generate a few polite times.

That silence is the gap between works for me and safe to launch, and it’s invisible from inside your own session — which is exactly where you decide to ship. launchworthy plays bouncer at the door of production: it knows a [HIGH] uncapped paid route will hurt you more than a [CRITICAL]-sounding jailbreak that can’t reach anything, it refuses to wave through the expensive finding just because the scary one is louder, and it hands you the limiter, not just the verdict. The hard part was never adding the rate limit. It was knowing that the twenty-minute fix, and not the cinematic one, was the thing standing between a clean launch and a very bad invoice.