Protect your OpenAI API route: auth, per-user limits and spend caps

To protect an OpenAI API route, make your server check who is calling before it spends a token: require a signed-in user, cap each user's calls per day, keep the provider key in server secrets, and set a hard monthly spend limit with the provider. A route that answers anyone works as a free AI proxy, and every call is billed to you.

The team behind Keybrum's attacksUpdated 16 min read

In short

  • An AI route that answers without a session is a free proxy to your model. Its path sits in your JavaScript bundle, and every prompt a stranger sends is billed to your provider account.
  • Moving the key to the server is only the first step. The route that holds the key needs a session check and a per-user quota, or it spends the key for anyone who asks.
  • On Supabase, verify_jwt is not a login check. It accepts the publishable key and the legacy anon key, which every visitor's browser has, so the function itself must reject callers without a user.
  • OpenAI, Anthropic and Google AI Studio all let you set a monthly spend cap. Turn one on as the backstop, knowing that OpenAI and Google both say enforcement lags a little behind real spend.
  • OWASP says it is unclear whether prompt injection can be fully prevented. Limit what the model can reach and do, and treat its output as untrusted input.
On this page

What an unprotected AI endpoint is

An AI endpoint is a server route that takes text from your app, adds your provider key and forwards the request to OpenAI, Anthropic, Gemini or another model. It is usually a Next.js route handler such as app/api/chat/route.ts, an Express handler, or a Supabase Edge Function. It is unprotected when nothing in that code checks who is calling or how much they have already used. The key stays hidden on the server, but the route spends it for anyone who sends a request.

OWASP lists this as LLM10:2025 Unbounded Consumption: an application that lets users run excessive, uncontrolled inference, with economic loss among the results. One of its named scenarios is Denial of Wallet, where an attacker sends a high volume of requests to exploit the pay-per-use pricing of AI services and the owner pays. There is a market for free model access. Sysdig's threat research team found attackers using a reverse proxy to hand out access to hijacked cloud-hosted models, apparently to sell it, while the account owner paid the bill. Sysdig put the worst case at over $46,000 a day. That case used stolen cloud credentials rather than an open route, but an open route gives the same access away with nothing to steal.

How AI routes end up open

These five patterns are the ones we run into in apps built with AI coding tools. Most come from the same habit: the coding agent protects the page and forgets the API route behind it.

PatternWhat it looks like in the codeWhat a stranger gets
Route with no auth checkThe chat page redirects to /login, but /api/chat calls the provider straight awayUnlimited prompts on your key, without an account
Key in the frontendNEXT_PUBLIC_OPENAI_API_KEY or VITE_OPENAI_API_KEY, or a fetch to api.openai.com from the browserThe key itself, usable from any script for anything the key allows
Edge Function trusting verify_jwtA Supabase function called with supabase.functions.invoke, with no user check in the handlerThe same as no auth: the public key in your bundle passes the platform check
Session check, no quotaThe route checks for a user, but anyone can sign up in secondsOne throwaway account, then unlimited calls
The request picks the parametersThe handler forwards model, max_tokens or the whole messages array from the request bodyYour most expensive model, the longest answers, and their own system prompt
Route paths are not secret: the browser has to know them, so they sit in your JavaScript bundle next to every other API call your app makes.

The first pattern is easy to miss because it looks finished. Every screen is behind a login, the feature works, and nobody notices that the route is a separate URL a script can call without ever loading the page. A login wall on the page protects nothing on the route.

The second is worse, because a leaked key works from anywhere and does not need your app at all. OpenAI's API reference is plain about it: do not expose your key in any client-side code such as browsers or apps, and load it from an environment variable or a key management service on the server. If an OpenAI, Anthropic or Gemini key has ever shipped in a deployed bundle, read what to do with a leaked API key first. Revoking it comes before everything else on this page.

What an open AI route costs: the limits that cap your worst month

Nothing in your code limits the bill of an open route. The ceiling is the cap you set with the provider or, if you set none, the monthly limit of your account tier, and that limit is high. Anthropic's tier caps run from $500 a month on the Start tier to $200,000 on Scale. Gemini's billing account caps run from $250 on Tier 1 to $20,000 or more on Tier 3. OpenAI assigns each organization a monthly usage limit based on its usage tier. All three providers let you set a lower cap yourself:

ProviderHard capWhere to set itKnow this
OpenAIMonthly spend limit per organization or per projectProject settings > Limits > Spend > Edit spend limit, then turn on Enforce a hard limitSpend alerts alone only notify, and traffic continues. At a hard limit, requests fail with 429 and project_spend_limit_exceeded. Enforcement is not instantaneous, so spend can slightly exceed the cap.
AnthropicMonthly spend limit per organization and per workspaceClaude Console, Billing page, Spend limits, then Set limitRequests over your limit return HTTP 400 with invalid_request_error. You cannot set a limit on the default workspace, so give your app's key its own workspace.
Google (Gemini API)Monthly spend cap per project, marked experimentalGoogle AI Studio, Spend page, Monthly spend cap > Edit spend capBilling data can lag about 10 minutes, and you pay for overages in that window. Batch jobs and agent sessions can run past the cap. Caps are not available on invoiced billing accounts.
Checked against each provider's documentation in November 2026. Menu names move, so confirm in your own dashboard.

A cap protects your wallet, not your feature. When it trips, the AI feature stops for every user, paying customers included, until the next month or until you raise it. That is why it comes last: the route's own checks should stop a stranger long before the provider does. Add spend alerts below the cap as well. OpenAI keeps alerts active next to a hard limit, so you hear about a spike before traffic stops.

How to check your own AI route

Four checks. The first two run on your code, the last two on your live app, and all of them only on apps you own.

  1. 1

    List every route that calls a model

    Search the project for the provider SDKs and hosts: openai, @anthropic-ai/sdk, @google/genai, api.openai.com, generativelanguage.googleapis.com. Include the supabase/functions folder. Each hit is a route to check, including old ones the app no longer calls but that are still deployed.

  2. 2

    Make sure no key reaches the browser

    Search the built files, not only the source: .next/static for Next.js, dist for Vite. A provider key there is public. Variables that start with NEXT_PUBLIC_ or VITE_ are written into the bundle at build time wherever your code reads them, by design. The API key leak checker names every key in a pasted bundle and runs in your browser.

  3. 3

    Call the route signed out

    Send a one-line prompt with no cookie and no token, as below. A 401 or 403 is what you want. Model text in the answer means anyone can use the route.

  4. 4

    Read your usage by project

    Open your provider's usage page and look for calls you cannot explain: traffic at hours your users sleep, a jump with no matching sign-ups, a model your app does not use. Give each app its own project or workspace so the numbers mean something.

bash
# Your own app only. No cookie, no Authorization header: what a stranger sends.
curl -i -X POST "https://your-app.example/api/chat" \
  -H "content-type: application/json" \
  -d '{"message":"Reply with the single word OK."}'
Change the path and the body field to match your route. A prompt this short costs a fraction of a cent.

For a Supabase Edge Function, send only the publishable key (or the legacy anon key), which is public anyway:

bash
curl -i -X POST "https://YOUR_PROJECT_REF.supabase.co/functions/v1/chat" \
  -H "apikey: YOUR_PUBLISHABLE_KEY" \
  -H "content-type: application/json" \
  -d '{"message":"Reply with the single word OK."}'
Model text in the answer means the function never checks for a user: verify_jwt let the request through because it accepts publishable keys.

A rate limit by IP address is not a pass either: it slows one script down and still does not know who is calling. For what a full test of an AI feature covers beyond this one route, read the AI penetration testing guide.

How to protect an OpenAI API route, layer by layer

Five layers, in this order. Each one covers a gap the previous one leaves open.

1. Keep the provider key in server secrets

The key lives in an environment variable that only server code reads, or in your platform's secret store: supabase secrets set for Edge Functions, the environment settings of your host for a Next.js app. Never give it a public prefix.

bash
# Copied into the browser bundle at build time: never do this
NEXT_PUBLIC_OPENAI_API_KEY=sk-...

# Read only by server code
OPENAI_API_KEY=sk-...
Rename the variable, rebuild, then revoke the old key: renaming does not remove it from bundles already deployed or cached.

2. Check the user first, then the quota, then call the model

Every AI route starts the same way: no user, no call. Then a per-user quota. Only then the provider. Here is a Next.js route handler with the parts that matter. getUser and takeQuota stand for your auth library and the quota function in step 3.

ts
// app/api/chat/route.ts
import { createHash } from "node:crypto";
import OpenAI from "openai";
import { getUser } from "@/lib/auth"; // Supabase, Clerk, Auth.js...
import { takeQuota } from "@/lib/quota";

// Reads OPENAI_API_KEY on the server. Short timeout, one retry: fail fast, not into a bill.
const openai = new OpenAI({ timeout: 30_000, maxRetries: 1 });
const MODEL = process.env.OPENAI_MODEL as string; // chosen by you, never by the request

export async function POST(req: Request) {
  const user = await getUser(req);
  if (!user) return Response.json({ error: "Sign in required" }, { status: 401 });

  const body = await req.json().catch(() => null);
  const message = body?.message;
  if (typeof message !== "string" || message.length > 4000) {
    return Response.json({ error: "Message missing or too long" }, { status: 400 });
  }

  if (!(await takeQuota(user.id))) {
    return Response.json({ error: "Daily limit reached" }, { status: 429 });
  }

  const response = await openai.responses.create({
    model: MODEL,
    instructions: "You answer questions about the user's recipes in this app.",
    input: message,
    max_output_tokens: 800,
    // A stable value per user, hashed so it carries no email or name
    safety_identifier: createHash("sha256").update(user.id).digest("hex"),
  });
  return Response.json({ text: response.output_text });
}
The request sends one field, the user's text. The model, the instructions, the maximum answer length and the timeout are set on the server.

On a Supabase Edge Function, keep verify_jwt on and declare that the function needs a user. With the @supabase/server package that Supabase's documentation now uses, that is one option:

ts
import { withSupabase } from "npm:@supabase/server@1";

export default {
  fetch: withSupabase({ auth: "user" }, async (req, ctx) => {
    const userId = ctx.userClaims?.id;
    // Count today's calls for userId and return 429 above your limit,
    // then call the provider with Deno.env.get("OPENAI_API_KEY").
    return Response.json({ ok: true });
  }),
};
auth: "user" accepts only a valid user JWT on the Authorization header, so a call that carries only the publishable key is refused before your handler runs.

Older functions do the same by reading the token from the caller's Authorization header and passing it to supabase.auth.getUser(token), returning 401 when there is no user. Either way, the handler itself must refuse a caller who is not signed in.

3. Give each user a daily quota

Count calls per user per day and refuse above the limit with 429. OWASP's guidance on unbounded consumption includes rate limits and user quotas, a cap on input size, and timeouts on long operations. A table in your own database is enough to start:

sql
create table public.ai_usage (
  user_id uuid not null,
  used_on date not null default current_date,
  calls integer not null default 0,
  primary key (user_id, used_on)
);
alter table public.ai_usage enable row level security; -- no policy: only your server touches it

-- In takeQuota(userId): one atomic statement, then compare the result with your limit
insert into public.ai_usage (user_id, calls) values ($1, 1)
on conflict (user_id, used_on) do update set calls = ai_usage.calls + 1
returning calls;
Count tokens instead of calls when answers vary a lot in length: each response reports the tokens it used.

Set the number from what a real user needs in a day, with a lower one for free accounts. If anyone can sign up in seconds, a quota per account is only as strong as account creation is slow, so also cap new accounts per day and total AI calls per hour across the app. Tying the feature to a confirmed email or a paid plan does more than any rate limiter.

4. Set an app-wide budget, then the provider cap

An app-wide daily budget, checked in the same function as the quota, turns the feature off cleanly with a message you wrote. The provider's hard limit from the table above sits behind it for the case your code misses. Keep each app on its own OpenAI project, Anthropic workspace or Google AI Studio project, so a spike in one app cannot drain the budget of another.

5. Log every call and pass a user identifier

Log the user id, the route, the model, the token counts the response reports, and the status. Your own logs are live, while provider dashboards can lag. Alert when one user goes far above the rest, or when a call arrives with no user at all, which step 2 should make impossible. With OpenAI, also send a safety_identifier: OpenAI asks for a stable string per user, with the username or email hashed, and says it helps trace misuse to one user, which lowers the chance that one user's misuse disrupts access for your whole organization.

The traps that stay open after the obvious fix

  • The request still picks the model. A handler that forwards model or max_tokens from the body lets a caller choose your most expensive model and the longest answers. Set both on the server.
  • The request sends the whole conversation. If the client posts a messages array, it can include its own system message and turn your recipe assistant into a general chatbot on your bill. Build the instructions on the server and accept only the user's text, plus history you stored yourself.
  • Preview deployments share the production key. A branch deployed before the fix still answers at its own URL, with the same key. Give previews a separate key with a small cap, or no AI key at all.
  • Retries and streams keep spending. A client that retries on every error, or a stream that keeps running after the user left, costs tokens nobody reads. Set timeouts and a maximum answer length.
  • Signed in does not mean trusted. The user id the route uses must come from the session, never from the request body, or one user can spend another's quota and read another's history. That is an IDOR on an AI route.

Prompt injection: what an app owner needs to know

Prompt injection is text that makes the model follow someone else's instructions instead of yours. It can come from the user's message, or from content the model reads: a web page, a PDF, an email, a database row. OWASP puts it first in its 2025 list for LLM applications and says that, given how models work, it is unclear whether fool-proof prevention exists. So plan for the model to be talked into things, and limit what that can cost you:

  • Keep secrets out of prompts. Your provider key, other API keys and other users' data never go into the instructions or the context. Assume a user can get the model to repeat anything it can see.
  • Give the model as little reach as possible. Tools it can call run with their own narrow permissions, enforced in your code, not in the prompt. OWASP's list starts with constraining the model's role and includes least privilege access.
  • Ask a human before anything you cannot undo. Sending email, refunds, deletes and changes to other accounts need a confirmation step outside the model.
  • Treat the output as untrusted input. Validate it against the format you expect with ordinary code before you store it, render it as HTML or pass it to another system.
  • Mark external content as external. Keep fetched pages and uploaded files apart from your instructions, and label them as untrusted data.

None of this stops a determined user from making your bot say something silly. It stops the bot from doing something expensive or irreversible because a document told it to.

Checklist for every AI route

  • The provider key is in server secrets, and no built file contains it.
  • The route returns 401 to a request with no session.
  • On Supabase, the Edge Function rejects a call that carries only the publishable key.
  • The user id comes from the session, never from the request body.
  • Each user has a daily quota, and the route returns 429 above it.
  • The model, the instructions and the maximum answer length are set on the server.
  • Input length is capped, and calls time out.
  • An app-wide daily budget turns the feature off before the provider cap does.
  • A hard spend limit is on at the provider, with alerts below it.
  • Every call is logged with the user id, the model and the token counts.
  • Tools the model can call have narrow permissions, and irreversible actions need a human.

What Keybrum tests on AI routes

Keybrum looks for API routes named like AI endpoints (chat, generate, summarize, completions and similar) and for Supabase Edge Functions whose names look like AI. It sends each one a one-line prompt with no session, asking for the single word OK, and records whether a model answered. The test costs a fraction of a cent, and routes or functions that look like actions, which could send emails or charge cards, are never called. It also scans your JavaScript for OpenAI, Anthropic and Gemini keys, and checks whether a found key works with a free call that lists models.

Every finding comes with the request that worked and the fix, which your coding agent reads through Keybrum's MCP server. After the fix, the same attack runs again, and on a plan it runs on every deploy. Keybrum only attacks apps whose owner proved ownership. For the other keys that belong only on a server, see API keys exposed in the frontend.

Questions

How do I stop OpenAI API key abuse?

If the key was ever in your frontend or a public repository, revoke it in the OpenAI dashboard and create a new one stored only in server secrets. Then make every route that uses it check for a signed-in user and a per-user quota, and turn on a hard spend limit for the project. Revoking alone is not enough while an open route keeps spending the new key.

Is it safe to call the OpenAI API directly from the browser?

Not with your API key. OpenAI's API reference says not to expose the key in any client-side code such as browsers or apps. Send the request to your own server route, which checks the user and adds the key.

How do I add rate limiting to an LLM API route?

Count calls or tokens per user per day in your database or a cache, keyed by the user id from the session, and return 429 above the limit. Add an app-wide budget on top, so a burst of new accounts cannot add up to a large bill. A limit per IP address helps against simple scripts but does not replace a per-user quota.

Does a provider spend limit stop all AI API cost abuse?

It caps the bill, with a small lag, but it does not stop the abuse: when the cap trips, the feature stops for every user. OpenAI says enforcement is not instantaneous and recorded spend can slightly exceed the cap, and Google says Gemini billing data can lag about 10 minutes, with overages in that window billed to you. Spend alerts on their own never stop traffic.

What authentication should an AI endpoint use?

The same session your app already uses for its pages, checked on the server inside the route: a Supabase, Clerk or Auth.js session, for example. A shared value in the frontend is not authentication, because every visitor has it. For calls between your own servers, use a secret key that never reaches a browser.

Is a Supabase Edge Function with verify_jwt protected?

Not by that check alone. verify_jwt runs before your code and rejects missing or malformed tokens, but it accepts publishable keys, which ship in every frontend, and the legacy anon key is a valid project JWT. Declare auth: "user" with @supabase/server, or check the user in the handler, and return 401 without one.

Can prompt injection steal my API key?

Not if the key never enters the prompt. A key read from server secrets and sent in a request header is invisible to the model. What injection can reach is whatever you put in the instructions, the context and the tools the model can call, so keep secrets and other users' data out of all three.

Sources

  1. 1.Spend limits, OpenAI API docs
  2. 2.API reference: authentication, OpenAI API docs
  3. 3.Safety best practices (safety identifiers), OpenAI API docs
  4. 4.Rate limits and spend limits, Claude Platform docs
  5. 5.Billing: spend caps, Google AI for Developers
  6. 6.Authorization headers and the verify_jwt check, Supabase Docs
  7. 7.Securing Edge Functions, Supabase Docs
  8. 8.LLM10:2025 Unbounded Consumption, OWASP Gen AI Security Project
  9. 9.LLM01:2025 Prompt Injection, OWASP Gen AI Security Project
  10. 10.LLMjacking: stolen cloud credentials used in new AI attack, Sysdig

Your app is public. So are its mistakes.

Check what any attacker can already see in it, free, in ten seconds. The full attack, with proof and fixes: €49 once, or €29 a month on a plan.

or continue with Google or GitHub