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.
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.
Hover or tap a component to see what it does.
Show text descriptionHide 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 type | Price (claude-opus-5-5, per million) | Notes |
|---|---|---|
| Input (uncached) | $4.00 | Everything 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.20 | Reusing a cached prefix: a 95% discount here |
| Output | $20.00 | Everything 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
Show text descriptionHide 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.
| Request | Uncached input | Cached input | Output | Total |
|---|---|---|---|---|
| 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.
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.
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.
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:
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
- Wire
createForUserinto a small route and call it ten times with different input lengths. - Sum the logged
usdvalues and compare with the usage page in the provider console after a few minutes. - Lower
DAILY_LIMIT_USDto0.001and confirm the route returns 429 with a friendly message.
Check your understanding
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
usageinto 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.
Related articles
- Choosing LLMs and Providers: Capability vs Cost vs LatencyHow to pick an LLM per feature by capability, cost, and latency: the current Claude lineup, effort levels, routing, and a harness to benchmark your own tasks.
- Security for LLM Apps: Prompt Injection, Data Leaks, and Agent RisksThreat-model an AI feature, defend against direct and indirect prompt injection, block exfiltration and SSRF, scope tools, and protect secrets and personal data.