Skip to content
AI Full-Stack Wiki

LLM API Keys, Billing, and Security for Web Developers

Keep LLM API keys server-side, understand token-based billing, compute the cost of every call, and enforce per-user budgets, timeouts, and retries in Node.js.

Difficulty: Beginner15 min readUpdated
On this page· Where the key lives

Why this matters

An LLM API key is a credit card that anyone can use with one HTTP request. Leaked keys are found by automated scanners within minutes of a public push, and an unprotected endpoint that calls a model is an open invitation to run up your bill. Treating keys and budgets as first-class engineering concerns from day one costs an afternoon; learning the lesson from an invoice costs far more.

Where the key lives#

The rule is short: the key only exists on your server. The browser authenticates with your app (a session cookie), and your server decides whether to call the model.

Where the model API key livesThe browser never sees the API key. It calls your server with a session cookie; your server reads the key from a secret store, enforces auth, rate limits, and budgets, and only then calls the model provider.BrowserYour serverauth, limits, budgetSecret storeenv per environmentUsage logtokens, cost per userModel APIspend limits set

Hover or tap a component to see what it does.

Keeping an LLM API key safe: the browser authenticates with a session cookie, and only your server reads the key from a secret store, checks limits and budgets, calls the model API, and logs cost.
Show text description

Everything except the browser runs on infrastructure you control. The key never travels toward the browser: not in JavaScript, not in a NEXT_PUBLIC_ variable, not in a response.

  • Browser: Untrusted. Anything shipped here (JavaScript, environment variables, network calls) is visible to the user.
  • Your server: The only code that holds the key. Checks the session, the user's rate limit, and spend before every model call.
  • Secret store: The key comes from the platform's encrypted environment settings or a secrets manager, separate for production, preview, and local.
  • Usage log: Every call records model, token usage, and computed cost, so you can bill, cap, and spot abuse.
  • Model API: The provider console has its own monthly spend limit as a last line of defense.

In practice:

  • Never ship the key to the browser. Not in client JavaScript, not in a NEXT_PUBLIC_ variable, not in an API response. Anything the browser receives, the user can read.
  • One key per environment: local development, preview deployments, and production each get their own key, with its own spend limit. You can then attribute usage and revoke one without breaking the others.
  • One key per service when several systems call the API, so a compromise of one is contained.
  • Set spend limits in the provider console (per workspace where available) as a last line of defense, and configure usage alerts below the limit.
  • Scan for secrets in your repository and CI, and never log request headers that contain the key.
  • Rotate keys on a schedule and immediately when a teammate with access leaves.

How billing works#

You pay per token, and different token types cost different amounts:

Token typePrice (claude-opus-5-5, per million)Notes
Input (uncached)$4.00Everything you send that was not read from cache
Cache write$5.00 (1.25×)First time a cached prefix is stored (5-minute cache)
Cache read$0.20Reusing a cached prefix: a 95% discount here
Output$20.00Everything the model writes, including reasoning

Prices are list prices at the time of writing; check the pricing page. Two numbers dominate most bills: output tokens, which cost several times more than input, and repeated input, such as a long system prompt or conversation history sent on every turn, which is exactly what prompt caching discounts.

  • Uncached input
  • Cached input reads
  • Output
What one LLM request costs, split into uncached input, cached input, and output tokens: caching a long shared prefix cuts this request's cost by more than half.
Show text description

One request to claude-opus-5-5 at list prices at the time of writing ($4 per million input tokens, $0.20 per million cached input tokens read, $20 per million output tokens). The cache write on the first request costs 1.25 times the input price and is not shown.

Cost per request in US dollars
RequestUncached inputCached inputOutputTotal
Without caching$0.0800$0.0000$0.0160$0.0960
With a cached 18,000-token prefix$0.0080$0.0036$0.0160$0.0276

The Batch API processes requests asynchronously at half price, which suits anything that does not need an answer within seconds: nightly classification, document indexing, eval runs.

Compute the cost of every call#

Every response includes a usage object. Turning it into dollars on each call gives you per-user budgets, per-feature dashboards, and early warning when a change makes requests more expensive.

lib/cost.ts
import type Anthropic from "@anthropic-ai/sdk";
 
// USD per million tokens at the time of writing. Keep this in one place and update it with the pricing page.
const PRICES = {
  "claude-opus-5-5": { input: 4, output: 20, cacheRead: 0.2 },
  "claude-sonnet-5-5": { input: 2, output: 10, cacheRead: 0.2 },
  "claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1 },
} as const;
 
export type PricedModel = keyof typeof PRICES;
 
const CACHE_WRITE_MULTIPLIER = 1.25; // 5-minute cache writes cost 1.25x the input price
 
/** Dollar cost of one response. `input_tokens` already excludes cached tokens. */
export function costUSD(model: PricedModel, usage: Anthropic.Usage): number {
  const price = PRICES[model];
  const cacheWrite = usage.cache_creation_input_tokens ?? 0;
  const cacheRead = usage.cache_read_input_tokens ?? 0;
  return (
    (usage.input_tokens * price.input +
      cacheWrite * price.input * CACHE_WRITE_MULTIPLIER +
      cacheRead * price.cacheRead +
      usage.output_tokens * price.output) /
    1_000_000
  );
}

Enforce per-user budgets#

Rate limits protect against bursts; budgets protect against slow, steady abuse and runaway loops. This helper keeps a daily spend counter per user in Redis, stored in micro-dollars so it stays an integer.

lib/budget.ts
import { Redis } from "ioredis";
 
const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");
 
export class BudgetExceededError extends Error {}
 
const key = (userId: string) => `spend:${userId}:${new Date().toISOString().slice(0, 10)}`; // one key per UTC day
 
export async function assertWithinBudget(userId: string, dailyLimitUSD: number) {
  const spentMicros = Number((await redis.get(key(userId))) ?? 0);
  if (spentMicros / 1_000_000 >= dailyLimitUSD) {
    throw new BudgetExceededError(`Daily budget of $${dailyLimitUSD} reached`);
  }
}
 
export async function recordSpend(userId: string, usd: number) {
  const k = key(userId);
  await redis
    .multi()
    .incrby(k, Math.round(usd * 1_000_000))
    .expire(k, 2 * 24 * 60 * 60)
    .exec();
}

One guarded entry point for model calls#

Route every model call in your app through one function. It is the natural place for budgets, cost logging, timeouts, and retries, and it keeps those rules from being forgotten in the twentieth endpoint.

lib/llm.ts
import Anthropic from "@anthropic-ai/sdk";
import { assertWithinBudget, recordSpend } from "./budget";
import { costUSD, type PricedModel } from "./cost";
 
// Reads ANTHROPIC_API_KEY from the server environment. One client per process.
export const anthropic = new Anthropic({
  timeout: 60_000, // milliseconds; generous for long answers, finite so requests cannot hang forever
  maxRetries: 2, // automatic retries with backoff on 408, 409, 429, 5xx, and connection errors
});
 
const DAILY_LIMIT_USD = 2;
 
export async function createForUser(
  userId: string,
  params: Anthropic.MessageCreateParamsNonStreaming & { model: PricedModel },
): Promise<Anthropic.Message> {
  await assertWithinBudget(userId, DAILY_LIMIT_USD);
 
  const message = await anthropic.messages.create(params);
 
  const usd = costUSD(params.model, message.usage);
  await recordSpend(userId, usd);
  console.info(
    JSON.stringify({
      event: "llm_call",
      userId,
      model: params.model,
      inputTokens: message.usage.input_tokens,
      outputTokens: message.usage.output_tokens,
      cacheReadTokens: message.usage.cache_read_input_tokens ?? 0,
      usd: Number(usd.toFixed(6)),
      stopReason: message.stop_reason,
    }),
  );
  return message;
}

And a route that turns failures into the right HTTP responses, using the SDK's typed errors rather than matching error message strings:

app/api/summarize/route.ts
import Anthropic from "@anthropic-ai/sdk";
import { BudgetExceededError } from "../../../lib/budget";
import { createForUser } from "../../../lib/llm";
 
export async function POST(request: Request) {
  // Stand-in to keep the example self-contained. NEVER trust a client-supplied header in a real app:
  // use getUserId() from your session helper (see "APIs and authentication").
  const userId = request.headers.get("x-user-id");
  if (!userId) return Response.json({ error: "Not signed in" }, { status: 401 });
  const { text } = (await request.json()) as { text: string };
 
  try {
    const message = await createForUser(userId, {
      model: "claude-sonnet-5-5",
      max_tokens: 1024,
      output_config: { effort: "low" },
      messages: [{ role: "user", content: `Summarize in two sentences:\n\n${text}` }],
    });
    const summary = message.content.flatMap((b) => (b.type === "text" ? [b.text] : [])).join("");
    return Response.json({ summary });
  } catch (error) {
    if (error instanceof BudgetExceededError) {
      return Response.json({ error: "You have reached today's limit. Try again tomorrow." }, { status: 429 });
    }
    if (error instanceof Anthropic.RateLimitError) {
      return Response.json({ error: "The assistant is busy. Please retry shortly." }, { status: 503 });
    }
    if (error instanceof Anthropic.APIError) {
      console.error("model call failed", error.status, error.message);
      return Response.json({ error: "The assistant is unavailable right now." }, { status: 502 });
    }
    throw error;
  }
}

Common mistakes#

  • Calling the model directly from the browser with a key in client code. Anyone can extract it in seconds.
  • No length limit on user input. Huge inputs are slow, expensive, and a denial-of-wallet vector. Cap characters before counting tokens.
  • Unbounded retries or agent loops. Retries multiply cost; loops without a step and spend cap can run for hours. The SDK's built-in retries plus a budget check are usually enough.
  • Logging full prompts and responses with personal data to a third-party logging service by default. Decide deliberately what you store, for how long, and who can read it.
  • One shared key for everything. You cannot attribute spend or revoke one environment without breaking the others.

Try it

Build a cost meter

  1. Wire createForUser into a small route and call it ten times with different input lengths.
  2. Sum the logged usd values and compare with the usage page in the provider console after a few minutes.
  3. Lower DAILY_LIMIT_USD to 0.001 and confirm the route returns 429 with a friendly message.

Check your understanding

Where should the model API key be read in a Next.js app?
Which usually dominates an LLM bill?
Why route every model call through one function like createForUser?

Key takeaways

  • The key lives only on your server, with one key per environment and service, spend limits, alerts, and rotation.
  • You pay per token type: output and repeated input dominate; caching and the Batch API cut costs.
  • Turn usage into dollars on every call, enforce per-user budgets, and log cost per feature.
  • Route all model calls through one guarded function with timeouts, retries, and typed error handling.

Next steps#

Finished this article? Track it to see your progress on the roadmap.