Skip to content
Relaykit
Guides

API Key Security for AI Apps: Rotation, Scoping, Rate Limits and Keeping Keys Server-Side

LLM API keys spend real money. How to keep them out of browsers and git, scope one key per app, rotate without downtime, rate-limit users and respond to a leak.

Relaykit TeamPublished 8 min read
On this page

In short:

  • An LLM API key is a spending authority. Anyone who has it can spend your balance, so treat it like a password, not like a config value.
  • Never ship a key to a browser or mobile app. Call the API from your server and expose only your own, rate-limited endpoint.
  • Use one key per app, environment and person, each with its own spending limit, so a leak has a small blast radius and is easy to trace.
  • Rotate on a schedule and immediately after any suspected exposure: create the new key, deploy, confirm, then revoke the old one.
  • Add your own per-user rate limits, input limits and max_tokens caps, and keep keys out of logs, screenshots and AI coding agents’ reach.

Most API keys protect data. LLM API keys also protect money: every request made with your key is billed to your balance, and there is a ready market for free access to frontier models. That changes the threat model. A leaked database password might never be used. A leaked LLM key can be found by automated scanners and drained quickly, because using it requires nothing more than a curl command.

This guide covers the practices that prevent most incidents, in the order we would implement them. They apply to any provider; where we mention features, they are the ones available on every Relaykit key in the console.

Rule 1: keys never reach the client

Anything you send to a browser or mobile app is public. Minified JavaScript is still readable, network requests are visible in developer tools, and app binaries can be unpacked. So this pattern is always wrong, no matter how hidden the key seems:

// DON'T: this key is shipped to every visitor of your site
const res = await fetch(API_BASE + "/v1/chat/completions", {
  method: "POST",
  headers: { Authorization: "Bearer sk-live-key-in-the-bundle" },
  body: JSON.stringify({ model, messages }),
});

A frequent way this happens by accident is a framework’s public environment-variable prefix. Variables starting with NEXT_PUBLIC_ (Next.js), VITE_ (Vite) or PUBLIC_ (Astro and SvelteKit) are deliberately embedded into client code at build time. A key placed in one of them is published with your site.

The correct pattern is a small backend endpoint:

  1. The browser calls your endpoint, for example POST /api/chat, with the user’s session cookie.
  2. Your server checks who the user is and whether they are within their limits.
  3. Your server calls the LLM API with the key from its own environment, and relays the answer (streamed, ideally).

Our streaming guide includes a minimal relay server you can start from. Serverless functions and edge functions work too, as long as the key is a server-side secret in that platform, not a public variable.

Rule 2: keys never enter version control

Once a key is in a git commit, assume it is leaked, even if the repository is private today. Repositories get forked, made public, cloned onto laptops and shared with contractors.

Keep keys in environment variables or a secret manager, and keep local .env files out of git:

# .gitignore
.env
.env.*
!.env.example
.claude/settings.local.json

Commit a .env.example with empty values so teammates know which variables to set. Then add a safety net:

  • Enable secret scanning and push protection on your git host if it offers them. GitHub, for example, can block a push that contains a recognised secret.
  • Run a pre-commit secret scanner such as gitleaks, so mistakes are caught on your machine before they become history.

If a key does land in a commit, rotating it is the fix. Rewriting history afterwards is good hygiene, but it cannot un-leak a key that was already pushed.

Rule 3: scope each key narrowly

A single key shared by every app, environment and developer is convenient until something goes wrong. Then you cannot tell where the spend came from, and revoking it breaks everything at once.

Create separate keys instead, and name them so the name says where the key lives:

Key name Used by Suggested limit
prod-web-backend Production server Sized to expected monthly spend plus headroom
staging Staging environment Small
ci-tests CI pipeline Very small; tests should use short prompts
alice-laptop-claude-code One developer’s coding tools Personal budget

In the Relaykit console, each key can have its own spending limit and expiry date, and keys can be restricted to an allowlist of models and of IP addresses. Use them:

  • Spending limits cap the damage of a leak or a runaway loop to that key’s limit, not your whole balance.
  • Expiry dates make temporary keys (a contractor, a hackathon, a demo) clean themselves up.
  • Model allowlists stop a key meant for a fast, inexpensive model from being used for a flagship model.
  • IP allowlists make a production key useless anywhere except your servers, if your servers have stable outbound IPs.

Prepaid billing adds a final backstop: usage can never exceed the balance you loaded. Combined with per-key limits, that gives you two layers of protection against surprise spend.

Rule 4: rotate without downtime

Rotation means replacing a key with a new one and revoking the old. Do it on a regular schedule, and immediately when a key may have been exposed, when someone with access leaves, or when a laptop is lost.

A zero-downtime rotation takes four steps:

  1. Create a new key with the same limits and restrictions.
  2. Deploy it: update the secret in your hosting platform or secret manager and redeploy or restart.
  3. Verify that traffic has moved: the usage log should show requests on the new key and none on the old one.
  4. Revoke the old key.

If rotation is painful, that is a sign the key is copied into too many places. Centralise it in one secret store per environment and inject it at runtime. For developer tools, Claude Code supports an apiKeyHelper setting that runs a command to fetch the key, for example from a password manager or vault, instead of storing it in a file.

Rule 5: rate-limit your own users

Keeping the key on the server is not enough if your public endpoint lets anyone send unlimited requests through it. Then your backend becomes the leaked key. Protect the endpoint the way you would protect any paid resource:

  • Require authentication for anything that triggers model calls, or put a strict anonymous quota in place.
  • Limit requests per user per minute and per day.
  • Limit input size, and set max_tokens on every call. Never let the client choose it freely.
  • Fix the model server-side. If the client can pick any model ID, an attacker will pick the most expensive one.
  • Watch spend daily and set the key’s spending limit as a hard ceiling.

A simple per-user limiter is enough to start with:

// Fixed-window limiter. In-memory: fine for one process.
// Use Redis or your database when you run several instances.
const WINDOW_MS = 60_000;
const MAX_PER_WINDOW = 20;
const windows = new Map(); // userId -> { start, count }

export function allowRequest(userId) {
  const now = Date.now();
  const w = windows.get(userId);
  if (!w || now - w.start >= WINDOW_MS) {
    windows.set(userId, { start: now, count: 1 });
    return true;
  }
  w.count += 1;
  return w.count <= MAX_PER_WINDOW;
}

// In your handler (Express-style):
app.post("/api/chat", requireLogin, async (req, res) => {
  if (!allowRequest(req.user.id)) {
    return res.status(429).json({ error: "Too many requests, try again in a minute." });
  }
  const prompt = String(req.body.prompt ?? "").slice(0, 8000); // cap input size
  // ...call the LLM API with a fixed model and max_tokens, then relay the answer
});

Also expect the gateway’s own limits: if you send too many requests at once you may receive 429 responses. Retry those with exponential backoff and jitter rather than immediately.

Rule 6: keep keys out of logs, screenshots and agents

Keys leak through side doors more often than through the front:

  • Logs and error trackers. Never log request headers wholesale; redact Authorization and x-api-key. Check that your error-tracking tool scrubs them too.
  • Screenshots, screen shares and support tickets. A terminal showing echo $ANTHROPIC_AUTH_TOKEN or a settings file is enough. Support should never need your full key; a key’s name or its last few characters is enough to identify it.
  • AI coding agents. Agents can read files in your project, including .env. In Claude Code, you can deny that explicitly in ~/.claude/settings.json with permission rules such as "deny": ["Read(./.env)", "Read(./.env.*)"]. Keep your own gateway key in the user-level settings file or an apiKeyHelper, never in a project’s shared .claude/settings.json.
  • Shared chat messages. Do not paste keys into team chats, issue trackers or AI chat assistants. Share them through a secret manager instead.

If a key leaks: a five-minute checklist

  1. Revoke the key in the console now. Do not wait to investigate first; investigation can happen afterwards.
  2. Create a replacement and deploy it (Rule 4 steps 1 to 3).
  3. Review the usage log for the leaked key: unfamiliar models, unusual hours or volume tell you whether it was used.
  4. Find the root cause: a commit, a public variable, a log, a screenshot. Fix that path so it cannot happen again.
  5. Contact support at support@relaykit.dev if you see usage you did not make, with the key name and time range.

The short checklist

  • Keys only on servers; nothing public-prefixed; nothing in client bundles or app binaries.
  • .env in .gitignore; push protection and a pre-commit scanner enabled.
  • One named key per app, environment and person, each with a spending limit; expiry for temporary keys; model and IP allowlists where practical.
  • Rotation documented and rehearsed; done immediately on suspected exposure.
  • Per-user rate limits, input limits, fixed models and max_tokens on your endpoints.
  • Keys redacted from logs; denied to coding agents; never pasted into chats.

Next steps

More in Guides

Try it

One API key for Claude, GPT, Grok and DeepSeek.

Relaykit speaks the OpenAI and Anthropic formats, so you change the base URL and the key, not your code. Top-ups start at $3, credit packs at $10, and every model has a published per-token rate.