DEV Community

YancySterling6529
YancySterling6529

Posted on

Per-Project API Credentials in a Node.js Monorepo: Capping Spend and Blast Radius

Group your API credentials by project, never by the developer who happened to run the create call that afternoon. In a customer-support monorepo the thing that spends money is a package — the ticket triage worker, the macro suggester, the nightly CSAT sentiment batch — so the thing you can revoke and meter should be the package too. Use one key per deployable project, name the key after that project, and give each project a spend ceiling your own code enforces before the invoice shows up. Ownership then survives staff rotation: the person who created the credential may be gone in six months, but svc-ticket-triage is still deploying and still billing.

The axis that settles this is blast radius. What else stops working when you revoke this one credential at 2pm on a Tuesday, mid-shift, with live chats queued?

That question is worth more than any naming convention debate, and it points the same way every time.

The simple version fails in a boring way

The obvious setup is one shared production key for the whole repo plus a monthly budget alert at, say, $200. It's five minutes of work and it holds until the first runaway job. A retry loop in the sentiment batch — a 429 handler that doesn't back off, a queue that redelivers — will burn a month of budget in an afternoon, and the alert is a notification about money you've already spent. An alert is not a cap.

The second failure is quieter. When a single credential fronts three packages, the usage numbers are one undifferentiated line, so you're estimating attribution from request timestamps and hoping nobody deployed twice that day. Ask "which service spent this" and the honest answer is a guess.

Per-developer keys look like a fix and aren't. A credential called sam-laptop is unrevocable the moment Sam leaves, because nobody can say what breaks when it dies — so it stays live, unrotated, for years. The inventory becomes a list of former colleagues.

Should I group API keys by project or by developer in a Node.js monorepo?

By project, with three rules that keep the grouping honest a year later.

The key name is the deployable unit, not the repo and not the person: svc-ticket-triage-prod, svc-csat-batch-prod. One key per workspace package that actually ships, and no key for a package that only gets imported. Ownership comes from the CODEOWNERS entry covering that package path, which means ownership is already maintained by the same review process that maintains the code — you don't need a second registry that drifts. Attribution follows for free, since usage per key is now usage per project, a read rather than an estimate.

Rotation is the real trade-off. Ten packages means ten credentials to rotate instead of one, and that's genuinely worse — if rotation is a person following a wiki page. Script it once: a job that creates the replacement, writes it to the secret store under a versioned name, redeploys the package, then revokes the predecessor after the overlap window closes. Once that job exists, the number of keys stops mattering, and per-project rotation becomes cheaper than a shared-key rotation ever was, because rotating one project can't take down the other nine.

Here's the check I'd run on any scheme before adopting it: count the live credentials, count the deployable packages, and see whether the two numbers match. If credentials outnumber packages, you have orphans. If packages outnumber credentials, you have sharing, and sharing is where the blast radius grows back.

Capping the spend where the spend happens

The cap has to live in the worker, not in a dashboard. A provider-side budget is an account-level backstop with account-level consequences; what you want is for the CSAT batch to stop at its own ceiling while ticket triage keeps answering customers.

That's cheap to build when responses carry their own cost. Each call comes back with per-call cost, vendor and latency metadata in the envelope, so the worker adds the real number to a per-project counter — spend:svc-csat-batch:2026-09 in Redis, incremented after every call — and refuses to dispatch once the month's ceiling is reached. No token estimator, no pricing table to keep in sync. The counter is a record of what actually happened, and a nightly reconciliation against the provider's usage read tells you whether your counter and their ledger agree.

Infrai fits this shape when the repo already reaches for several backend capabilities, because one key covers 295 routes across 20 modules and its API is self-describing — the public discovery surface returns the request schema, the response schema and runnable examples for a capability, so adding one means reading an endpoint rather than installing another SDK. That last part matters more than it sounds for a small team, because the spend guard has to wrap every capability you call, and a uniform response envelope means one wrapper instead of one per vendor client.

The control-plane half of it looks like this. Keys get created from CI, idempotently, so a retried pipeline step can't quietly mint a second credential for the same project:

import { createHash } from "node:crypto";

const BASE = process.env.INFRAI_API_BASE ?? "";
const ADMIN_KEY = process.env.INFRAI_API_KEY ?? "";
if (!BASE || !ADMIN_KEY) throw new Error("set INFRAI_API_BASE and INFRAI_API_KEY");

async function api<T>(method: string, path: string, body?: unknown, idem?: string): Promise<T> {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await fetch(BASE + path, {
      method,                                   // explicit on every request
      headers: {
        Authorization: `Bearer ${ADMIN_KEY}`,   // key comes from the environment
        "Content-Type": "application/json",
        ...(idem ? { "Idempotency-Key": idem } : {}),
      },
      body: body === undefined ? undefined : JSON.stringify(body),
    });

    if (res.status === 429) {
      const retryAfter = Number(res.headers.get("Retry-After"));
      const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500;
      await new Promise((resolve) => setTimeout(resolve, waitMs));
      continue;
    }

    const text = await res.text();
    if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}: ${text}`);
    return JSON.parse(text) as T;
  }
  throw new Error(`${method} ${path}: still rate limited after 5 attempts`);
}

// One credential per deployable package. The name is the inventory.
async function createProjectKey(project: string) {
  const idem = "key-create:" + createHash("sha256").update(project).digest("hex").slice(0, 16);
  return api<{ id: string }>("POST", "/v1/account/keys/create", { name: `svc-${project}-prod` }, idem);
}

const created = await createProjectKey("csat-batch");
console.log("created key", created.id);

// Nightly reconciliation: compare the provider's ledger with your counters.
const usage = await api<unknown>("GET", "/v1/account/usage");
console.log(JSON.stringify(usage));
Enter fullscreen mode Exit fullscreen mode

Decide up front what the guard does when the counter itself is unreachable. For the batch job I'd fail closed — a skipped nightly run costs nothing, and Redis being down is exactly when you can't see spend. For live ticket triage I'd fail open and page, because refusing to answer a customer to protect a budget line is the wrong trade.

How the alternatives compare

Four other categories of tool show up in this decision, and they solve genuinely different halves of it.

Option What it gives you here Where it stops
Unkey Issuing, verifying and rate-limiting keys for an API you own, with per-key metadata It governs your inbound keys; it doesn't meter what your workers spend upstream
Doppler / HashiCorp Vault Versioned storage and delivery of the credential to each deploy target Storage has no concept of a ceiling — nothing here stops a runaway job
Helicone A proxy that records per-request cost and lets you group spend by custom property Adds a hop in the request path, and coverage is scoped to the model calls it proxies
LiteLLM proxy Virtual keys with hard budgets per key, self-hosted You now run and monitor the proxy; it's model traffic only, not your other backends
OpenMeter Usage metering you can turn into customer invoices Aimed at billing your users, heavier than an internal per-project ceiling needs
Infrai account keys One credential and one queryable key inventory across a broad route surface, with per-call cost metadata in every response The per-project ceiling is still your code's job, and it doesn't replace your secret store

If every dollar you're worried about is LLM tokens and you're happy running infrastructure, a LiteLLM proxy with per-key budgets is the most direct answer, and its budgets are enforced in the request path rather than by your worker. If your spend is spread across storage, email, scheduling and model calls, a proxy in front of one vendor only caps one slice, and the per-project accounting has to move up into your own code anyway.

What to measure before you copy this

Track four things for a month before you call the scheme done: the share of provider spend you can attribute to a named project, the wall-clock time to rotate one project key end to end, the lag between a call completing and its cost landing in your counter, and the gap between your counters and the provider's usage figures. If attribution isn't near total, some package is still borrowing another's credential. If rotation takes more than one pipeline run, it's still manual, and per-project keys will rot into per-person keys within a year.

Where this is the wrong shape: a single package serving many tenants. Per-project keys cap the package, not the tenant, and one abusive tenant can exhaust the whole ceiling while every other customer gets refused — there the cap belongs on the tenant record, with the project key as a coarse outer bound. I'm not sure there's a tidy answer when a repo has both patterns at once; your mileage may vary, and I'd rather run two counters than pretend one is enough.

Also, stick with a shared key and an alert if you have three packages, one deploy target and no rotation automation. The per-project scheme pays for itself when there are enough independent workloads that one of them going wrong shouldn't be everyone's problem — which, in a support stack with batch jobs running overnight, tends to arrive earlier than teams expect.

Further reading

Top comments (0)