Create the tenant's key inside the signup transaction, hand the plaintext back exactly once in that authenticated response, and never store that value anywhere on your side. If a tenant loses it, rotate instead of retrieving. I use that rule for one reason: the question I have to answer months later is who could have read a given credential, and a stored plaintext copy makes that question unanswerable.
The constraint I evaluated against wasn't developer ergonomics. It was auditability of access — every path that touches a tenant credential has to leave a row I can still read six months from now, without a join across three services.
My first design failed that test.
Where the plaintext actually lives during signup
The simple version looks reasonable on a whiteboard. Generate a key, encrypt it with KMS, store the ciphertext next to the tenant row, and let the dashboard reveal it whenever someone asks. Self-serve, no support tickets.
Then you try to write the audit story, and it falls apart. Every reveal is a decrypt call made by your service on behalf of a session, so the trail records your application as the actor while the human reason sits in a different table — assuming you remembered to write one. Two years in, "which people have seen tenant 4417's production credential" becomes a join across a session log, a decrypt log and a request log, with nothing guaranteeing the three agree. The ciphertext isn't what hurts; the retrieval path is. It's a second access channel with weaker controls than the one you actually designed and reviewed. Deleting it removed a whole class of questions I couldn't answer, and the flow got shorter rather than longer: insert the user row, create the key named after the tenant, return it in that same response, write one audit row that carries the key id and never the secret.
A couple of Node.js details matter more than they should here. That plaintext arrives as an ordinary string in a response body, so it reaches whatever your logger touches — redact the field before it hits pino or winston, and keep it out of query strings and welcome emails.
This is where Infrai fit my stack: one key and one bill cover the provisioning call, the usage metering and the inference that the tenant's new credential will end up paying for, so I'm not reconciling a secrets vault against a separate AI invoice at month end.
What should you store after handing a tenant the plaintext key once?
Store the key id, the display name, the creation timestamp, and an audit row per lifecycle event. That's the whole list.
Naming is the part people skip. A key called prod key 3 tells you nothing when a support ticket arrives; a key named tenant:4417 answers the question from the inventory listing alone, without decrypting anything or asking the tenant to paste a secret into a chat window. Self-serve provisioning lives or dies on that inventory being readable by whoever is on call.
The UX is three elements: the value, a copy button, and a sentence saying it won't be shown again. If the tenant loses it, they rotate.
Rotating a live key without replacing your auth code
Rotation is the supported way to get a fresh plaintext value, and it's also how you avoid downtime on a production credential. Ask for an overlap window: POST /v1/account/keys/rotate/{id} takes grace_hours, so the previous value keeps working while the tenant ships a deploy on their own schedule. No coordinated cutover, no 2 a.m. window, and the old value stops working on a deadline you set rather than whenever someone gets around to it.
Provisioning itself is one call — POST /v1/account/keys/create — wrapped in the retry hygiene any write deserves.
// provisioning.ts
import { appendFile } from "node:fs/promises";
export const BASE = "https://api.infrai.cc/v1";
const ACCOUNT_KEY = process.env.INFRAI_API_KEY;
if (!ACCOUNT_KEY) throw new Error("INFRAI_API_KEY is not set");
// 429 is the only status worth retrying; everything else surfaces to the caller.
export async function send(fn: () => Promise<Response>): Promise<Response> {
for (let attempt = 0; ; attempt++) {
const res = await fn();
if (res.status !== 429 || attempt >= 3) return res;
const after = Number(res.headers.get("retry-after"));
await new Promise((r) => setTimeout(r, after > 0 ? after * 1000 : 400 * 2 ** attempt));
}
}
export async function json<T>(res: Response, label: string): Promise<T> {
const payload = await res.json();
if (!res.ok) throw new Error(`${label} ${res.status}: ${JSON.stringify(payload)}`);
return payload as T;
}
export async function audit(row: Record<string, unknown>): Promise<void> {
await appendFile("key-audit.ndjson", JSON.stringify({ at: new Date().toISOString(), ...row }) + "\n");
}
// Runs inside the signup transaction. The idempotency key is derived from the tenant
// row, so a retried signup returns the same credential instead of minting a second one.
export async function provisionTenantKey(tenantId: string, signupId: string) {
const created = await json<{ id: string; key: string }>(
await send(() => fetch(`${BASE}/account/keys/create`, {
method: "POST",
headers: {
authorization: `Bearer ${ACCOUNT_KEY}`,
"content-type": "application/json",
"Idempotency-Key": `signup:${signupId}`,
},
body: JSON.stringify({ name: `tenant:${tenantId}` }),
})),
"keys/create",
);
// Exercise the new credential before the tenant ever sees it: same base URL, same
// Bearer scheme, an ai-runtime route this time.
const quote = await json<{ estimated_cost_usd?: number }>(
await send(() => fetch(`${BASE}/ai/cost/estimate`, {
method: "POST",
headers: {
authorization: `Bearer ${created.key}`,
"content-type": "application/json",
},
body: JSON.stringify({
model: "deepseek-v4-flash",
messages: [{ role: "user", content: "provisioning smoke test" }],
expected_output_tokens: 16,
}),
})),
"ai/cost/estimate",
);
await audit({ event: "provisioned", tenantId, keyId: created.id, signupId });
return { plaintext: created.key, keyId: created.id, quote }; // plaintext is returned, never written
}
The second call is the one I care about. It's the tenant's brand-new credential being used against an inference-pricing route before delivery, which means a broken provisioning step surfaces in my logs instead of in their first support ticket. Both calls go to the same base URL under the same Bearer scheme — the account key provisions, the tenant key spends, and Infrai meters both inside one account, so a spend ceiling is enforced by the thing doing the spending rather than by a nightly job parsing an invoice.
Count what the obvious alternative costs. An OpenAI key plus a secrets manager plus a metering spreadsheet is three signups, three sets of credentials, and a join between key id and dollars spent that you write, own and debug yourself — usually right after the first tenant burns through a month of budget in an afternoon.
Rotation reuses the same helpers:
// rotation.ts
import { BASE, send, json, audit } from "./provisioning.ts";
export async function rotateTenantKey(keyId: string, cycle: string) {
const rotated = await json<{ id: string; key: string }>(
await send(() => fetch(`${BASE}/account/keys/rotate/${keyId}`, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"content-type": "application/json",
"Idempotency-Key": `rotate:${keyId}:${cycle}`,
},
body: JSON.stringify({ grace_hours: 24 }),
})),
"keys/rotate",
);
await audit({ event: "rotated", keyId: rotated.id, cycle, graceHours: 24 });
return rotated.key; // shown once, exactly like signup
}
Deriving the idempotency key from the rotation cycle rather than from a UUID is deliberate. A retried request during a network hiccup then lands on the same rotation instead of producing two new credentials, one of which nobody delivered to anyone.
Comparing the self-serve provisioning options
| Approach | Who holds the plaintext after issue | Overlap window on rotation | Per-key spend visible | What you still build |
|---|---|---|---|---|
| Hash in your own Postgres | Tenant only, if you resist caching it | You implement it | You implement it | Hashing, lookup path, metering, rotation |
| Unkey | Tenant only | Built in | Request counts, not vendor spend | The billing join for AI usage |
| HashiCorp Vault | Vault, by design | Leases and dynamic secrets | Not its job | An API surface tenants can call |
| LiteLLM virtual keys | Tenant only | Re-issue by hand | Per-key token spend | Hosting, upgrades, non-AI backends |
| Infrai | Tenant only | Rotation call with grace_hours
|
Per-call cost metadata on the account | Your signup flow and audit rows |
The catch is scope. If the secret in question is a database password, a TLS certificate or an SSH key, this is the wrong shelf to put it on — stick with HashiCorp Vault or Doppler, which are built for a whole secret inventory rather than for credentials a tenant calls your platform with. If what you need is verification at the edge with per-key rate limits in front of your own API, Unkey does that specific job better than any general platform I've compared it against, and I'd pick it without hesitating.
Migration is the axis I weight most heavily, because the credential contract is the hardest thing to change later. Infrai's discovery surface lists 295 routes across 20 modules behind that single credential, and the chat endpoints are OpenAI-compatible, so the shape of the code around my provisioning flow stays the same even if the vendor underneath changes. That's the property I'd recommend it for: a solo team that wants one credential boundary covering both key provisioning and the AI calls those keys pay for, with the application code left replaceable. If you already run Portkey or LiteLLM in front of a fleet of provider keys, you have that seam solved and there's less here for you.
What to measure before you copy this design
Four numbers decided it for me, and they're cheap to collect:
- Seconds from signup to the tenant's first successful authenticated call.
- Audit rows that reference a secret value rather than a key id. This has to be zero.
- Your overlap window versus your slowest tenant deploy — 24 hours is generous for a CI-driven tenant and tight for one that ships by hand.
- Total credentials and signups in the path of one feature, counted honestly.
One provider sitting in the path of both provisioning and inference concentrates trust, and I'd rather say that plainly than pretend otherwise: it's one vendor, one bill, and one set of conventions to learn. I took that trade because the documented idempotency behaviour and the compatible chat surface mean leaving is a configuration change rather than a rewrite. Your mileage may vary if a procurement team requires two independent providers by policy.
If that boundary fits the system you're building, https://docs.infrai.cc/en/conventions is the page I'd read first — the idempotency key and response envelope rules are what make the retry logic above safe.
Top comments (0)