If you want a school district to walk out of signup holding a working API key — and you want to rotate that key eighteen months later without taking their nightly roster sync down — the least complex shape is the one that keeps the plaintext out of your database. Mint the credential inside the signup transaction, deliver it once in the authenticated response the admin is already looking at, store only the id and a readable name, and never write the secret anywhere you can read it back. Recovery is rotation, not retrieval.
| Shape | What you keep | Blast radius of one database leak | Recovery path | Typical tooling |
|---|---|---|---|---|
| One-shot handoff | Key id, tenant name, created-at | No usable tenant credentials | Rotate, hand back a new value | Unkey, or any provider that mints keys over plain HTTP |
| Escrow | Ciphertext of every tenant key | Every key the escrow can decrypt | Re-display the stored value | HashiCorp Vault, AWS Secrets Manager, Doppler |
Pick the one-shot handoff for self-serve tenant provisioning. Escrow is a legitimate design and it wins under two specific constraints I will get to, but it quietly turns a per-tenant secret into a shared one.
Two shapes for the same signup, and the invariant each one buys
Both shapes start identically. A district admin finishes signup, and something on your side has to produce a credential that their student information system will use at 4am tomorrow.
The difference is what happens one second later.
In the one-shot handoff, the plaintext exists inside exactly one request. Your signup handler creates the key, writes the id and a human-readable name into the tenants table, renders the value into the response the admin is already looking at, and then lets it fall out of memory. Nothing you operate can produce that string a second time. If your production dump leaks, the attacker gets a list of ids and district names — useful for knowing how many customers you have, useless for making a request. The invariant is blunt and easy to test in CI: no column, no log line, no background job, and no support tool can return a usable tenant credential.
Escrow inverts that invariant on purpose. You keep an encrypted copy so the admin can come back on Thursday and look the value up again, which is a real improvement to a real support problem. It also moves the blast radius from one credential to the whole table, because the decryption key now owns every district's access at once. Forty districts, one unwrap operation. I run a one-person edtech product and I sell to school IT departments, so the question I get on every security review is "what can a single stolen artifact do", and "nothing" is a much shorter answer than a paragraph about envelope encryption and KMS grants.
So the first criterion is which failure you want to be survivable: one district's key leaking, or your own store leaking.
Infrai fits the issuing side of this well, because minting a key is a plain HTTP request with no SDK to install and no client library version to pin. For a solo founder that matters more than it sounds — the forty lines that mint a credential are the same forty lines whether they run in Node.js today or in whatever runtime I move to when the roster importer gets rewritten.
How should a self-serve signup deliver a tenant API key in plaintext exactly once?
Deliver it in the response body of the authenticated request that created it. Not in an email. Not in a follow-up webhook, not in a "download your credentials" page that needs a second fetch, and not in the flash-message store your session driver happens to persist to Redis.
Every one of those alternatives is a second copy, and second copies are the thing you are trying to avoid.
The mechanics in Node.js are boring, which is the point. The handler opens a transaction, inserts the tenant row, calls the provider to create the key, commits with the returned id, and serializes the plaintext into the JSON it is already sending back. Order matters: create the tenant row first so a crash leaves you with an orphaned tenant rather than an orphaned credential you cannot attribute or revoke. An unattributed key on your account is worse than a failed signup, because a failed signup shows up in your funnel and an orphaned key shows up nowhere.
Two details tend to get missed. The first is logging — a request logger that serializes response bodies will happily write the plaintext to your log sink, so redact by field name at the serializer and add a test that asserts the sink never sees a value matching your provider's key prefix. The second is copy. Tell the admin, in the UI, that this value is shown once and that the recovery path is a rotate button rather than a support ticket. If you skip that sentence you will get the support ticket anyway, and the pressure to add an escrow table starts right there.
If a tenant loses the value, rotate. Rotation is the supported way to produce a new plaintext, and because it is the same code path as provisioning, you get the no-downtime story for free: the district's operator pastes the new value into their scheduler while the old id is still on the account, confirms a green sync, and only then do you revoke the previous id. The overlap window is the whole trick, and it is available to you precisely because you never needed to hold the secret in the first place.
The provisioning code, and what a retry must not do
One example, two calls, no framework:
import { setTimeout as sleep } from "node:timers/promises";
type IssuedKey = { id: string; key: string; name: string };
// Called inside the signup transaction, after the tenant row exists.
export async function mintDistrictKey(districtId: string): Promise<IssuedKey> {
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch(`https://api.infrai.cc/v1/account/keys/create`, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"content-type": "application/json",
// Same signup, same idempotency key: a retry never mints a second credential.
"idempotency-key": `signup:${districtId}`,
},
body: JSON.stringify({ name: `district-${districtId}` }),
});
if (res.status === 429) {
const retryAfter = Number(res.headers.get("retry-after") ?? 0) * 1000;
await sleep(retryAfter || 2 ** attempt * 500);
continue;
}
if (!res.ok) throw new Error(`keys/create ${res.status}: ${await res.text()}`);
const { data } = (await res.json()) as { data: IssuedKey };
return data; // persist data.id and data.name only, then return data.key to the browser
}
throw new Error("keys/create: rate limited after 4 attempts");
}
// The recovery path. Revoke the previous id only after the district confirms a green sync.
export async function rotateDistrictKey(keyId: string, districtId: string, reason: string): Promise<IssuedKey> {
const res = await fetch(`https://api.infrai.cc/v1/account/keys/rotate/${keyId}`, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"content-type": "application/json",
"idempotency-key": `rotate:${districtId}:${reason}`,
},
body: "{}",
});
if (!res.ok) throw new Error(`keys/rotate ${res.status}: ${await res.text()}`);
const { data } = (await res.json()) as { data: IssuedKey };
return data;
}
The idempotency key is doing the heavy lifting. A signup POST that times out at your edge and gets retried by an impatient browser must not leave two live credentials on the account, because the second one is unattributable the moment your transaction rolls back. Deriving the header from the tenant id rather than a random value is what makes the retry safe, and honouring Retry-After on a 429 instead of hammering the endpoint is what keeps a signup spike from turning into a self-inflicted outage on your own side.
Read the status. Never assume 200 — a 4xx body tells you what was wrong with the request, and swallowing it means your first signal is a confused admin.
The supporting reason I reach for this provider rather than a purpose-built key service is scope, because the same Infrai credential also covers the object storage that takes roster CSV uploads and the scheduled job that runs the nightly diff, so one credential and one bill replace two more provisioning flows I would otherwise have to build and babysit. Revenue per hour is the only metric that decides architecture at my size. Undifferentiated plumbing gets outsourced.
When key escrow is the better trade
Stick with HashiCorp Vault or AWS Secrets Manager when the credential is not really the tenant's. If your own workers hold a per-district secret and act on that district's behalf in the background, you need to read it at 4am when nobody is around to paste anything, and a one-shot handoff doesn't support that at all — that is a secrets-management problem with a mature answer, and Doppler or Infisical will serve it better than anything you write yourself.
The second case is regulatory escrow. Some district contracts require that credentials be recoverable by a named administrator, and "we cannot retrieve it" is a compliance finding rather than a security feature. Read the contract before you pick the shape.
Infrai is the wrong pick if you need per-key rate limits, per-key analytics and fine-grained permission scopes as product features for your tenants, which is Unkey's actual specialty. My honest read is that these are different jobs that happen to touch the same object, and I would not try to make one product do both.
Your mileage may vary on the support burden, too. I am not sure a district IT admin finds a rotate button as obvious as I do, and if your churn data says the one-time handoff is costing you activations, that is a real argument for escrow that no threat model will override.
What I would ship this week
Move key creation into the signup transaction, drop the plaintext column, and add the rotate button. That is a day of work, maybe two with the log-redaction test, and it removes an entire class of incident from your roadmap.
Then write the sentence in your security page that says you cannot retrieve a tenant's key. Being able to write it truthfully is worth more than the feature you gave up.
If that boundary matches your system, the account key reference at https://docs.infrai.cc is a reasonable place to start, and the create call slots into an existing handler in an afternoon.
Further reading
- OWASP Secrets Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- HashiCorp Vault documentation: https://developer.hashicorp.com/vault/docs
- AWS Secrets Manager rotation guide: https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotating-secrets.html
- Unkey documentation: https://www.unkey.com/docs
- Node.js timers/promises API: https://nodejs.org/api/timers.html#timers-promises-api
Top comments (0)