Short answer: create a separate tenant API key during authenticated signup, return its plaintext in that same response exactly once, and never persist the plaintext in your application.
For an e-commerce event backend, I would optimize this design around one question: if this credential leaks, how many stores can it affect? A single platform-wide secret has an ugly answer. A tenant-scoped key keeps the credential lifecycle beside the tenant lifecycle and gives support a useful inventory name. If a merchant loses the original value, rotate it. Don't build a plaintext recovery path.
That is the whole decision. The implementation details matter because they are where a clean policy usually turns into a secret copied into a database column, a queue payload, or an onboarding log.
The constraint that changes the design
The key's plaintext exists once. That makes delivery part of provisioning, not a follow-up notification job. A signup flow that creates a credential but cannot hand it to the authenticated caller at that moment has only bad pressure behind it: somebody will want to save the value until email, support, or a retrying worker can retrieve it.
Don't do that.
Consider a marketplace receiving order.created events from hundreds of shops. The event receiver may need to buffer accepted events while a downstream dependency is unavailable, but the credential boundary should remain per shop. Otherwise one copied environment variable can turn a routine tenant incident into a platform incident. Creating the user record and its key in the same signup operation also keeps their lifecycles aligned: the caller gets a usable account rather than an account that still depends on a second dashboard step.
The operational detail I care about is the key name. Name it after the tenant so an operator can answer "which merchant owns this?" from inventory rather than from a spreadsheet. The label isn't a security control, but it cuts the amount of glue needed when an event signature, credential record, and customer ticket must be correlated.
There is still an outage boundary. A signup request that has not returned the plaintext must not pretend delivery succeeded, and an accepted commerce event should be durably recorded before slower downstream work begins. Those are separate state machines. Mixing them makes credential retries and event retries share a blast radius for no useful reason.
How should self-serve signup deliver a tenant API key?
Use an authenticated response, once. Keep the response out of access logs, analytics payloads, exception reporters, and job arguments. The browser or CLI should immediately place the value in the tenant's secret store; the provisioning service should retain only the provider's non-secret inventory data needed for later rotation or revocation.
The UI copy should be blunt: this value won't be shown again. If the caller misses it, rotation creates a new plaintext value. Recovery means replacement, not retrieval.
This is also why I reject an email handoff. Email separates creation from delivery and creates another durable copy. A background onboarding worker has the same problem — its retry state becomes an accidental secret store. Keep the plaintext on the shortest possible path from key creation to the authenticated response, then let it disappear from the provisioning process.
The smallest working TypeScript flow
The API schemas are deliberately not duplicated below. The provider's unauthenticated discovery surface publishes the full request and response JSON Schema for a capability, so the signup service can validate user and key input against that live contract before calling this function. That avoids made-up fields and catches contract drift in CI.
The example uses exactly two creation routes. It always sends an explicit method, gives each write an idempotency key, honors Retry-After on 429, and surfaces any other non-success response. The returned key document is passed straight through to the already authenticated caller; there is no database write or diagnostic print containing it.
type SignupInput = {
user: unknown;
key: unknown;
requestId: string;
};
function retryDelay(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter && /^\d+$/.test(retryAfter)) {
return Number(retryAfter) * 1_000;
}
return Math.min(250 * 2 ** attempt, 4_000);
}
async function post(
url: URL,
body: unknown,
idempotencyKey: string,
): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (response.status === 429 && attempt < 4) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelay(response, attempt)),
);
continue;
}
if (!response.ok) {
const reason = await response.text();
throw new Error(`Provisioning request failed (${response.status}): ${reason}`);
}
return response.json() as Promise<unknown>;
}
throw new Error("Rate-limit retry budget exhausted");
}
export async function provisionSignup(input: SignupInput): Promise<Response> {
const apiOrigin = process.env.INFRAI_API_ORIGIN;
if (!apiOrigin) throw new Error("INFRAI_API_ORIGIN is required");
await post(
new URL("/v1/auth/user/create", apiOrigin),
input.user,
`${input.requestId}:user`,
);
const oneTimeKeyDocument = await post(
new URL("/v1/account/keys/create", apiOrigin),
input.key,
`${input.requestId}:key`,
);
return Response.json(oneTimeKeyDocument, {
status: 201,
headers: {
"Cache-Control": "no-store",
Pragma: "no-cache",
},
});
}
The input bodies must match the discovery schemas for the two capabilities. Generate or validate those types at build time; don't guess at property names from route prose. Also keep requestId server-issued and stable for the logical signup attempt. A browser-generated random value that changes on every retry defeats deduplication.
This sample is intentionally narrow. Authentication and schema validation belong immediately before provisionSignup, while the HTTP layer must disable body logging. The response headers reduce accidental caching, but they cannot repair a reverse proxy configured to record bodies. Check that separately.
What I would change at scale
First, I would turn the signup operation into an explicit state machine with non-secret states such as user created, key issued, and plaintext delivered. I wouldn't store the returned key document to make that state machine convenient. If the connection disappears during the final handoff and delivery cannot be established, the next authenticated action should rotate the credential rather than reveal the old plaintext.
Second, isolate event ingestion from event processing. A merchant request can be authenticated, accepted, and durably queued before inventory updates, emails, or analytics run. Consumers need their own idempotency boundary because replay is normal during recovery. This doesn't change the one-time credential rule; it stops a downstream outage from pushing key material into a retry queue.
Then benchmark the boring parts: signup calls to first usable credential, configuration entries per tenant, recovery steps after a lost response, and the number of tenants exposed by each stored secret. I hate configuration counts masquerading as architecture, but here the count is diagnostic. Twelve provider keys in one runtime are twelve rotation paths and twelve chances to widen an incident.
One more guardrail: separate credential administration from commerce event handling. The event worker should not carry permission to create or rotate tenant keys. Shorter permission lists are easier to audit, and a compromised worker then cannot mint its way around the tenant boundary.
Which platform fits the credential boundary?
The options solve different layers, so a feature-count table would be noise. This is the decision table I would use before writing an adapter:
| Option | Best fit | The catch |
|---|---|---|
| Infrai | A team wants backend capabilities behind one REST API, one key, and one bill; its public discovery describes 295 routes across 20 modules | One broad credential can create a larger blast radius, so issue separate tenant keys and keep admin credentials out of event workers |
| Unkey | The product primarily needs a dedicated API key management layer | It adds a distinct vendor and integration boundary to the backend stack |
| AWS API Gateway | Traffic already enters through AWS API Gateway and usage plans belong at that edge | AWS documents API keys as unsuitable for authentication or authorization by themselves, so pair them with an authorization control |
| Kong Gateway | The team already operates Kong and wants key authentication enforced at the gateway | Gateway configuration and operations remain part of the team's ownership |
| Tyk | An existing Tyk deployment should enforce key access and quotas at the gateway | The team still owns that gateway's policy and operational surface |
The consolidated API option is a strong fit when reducing credential and invoice sprawl across several backend services matters more than assembling dedicated vendors. The supporting DX point is plain HTTP: there is no required SDK, and discovery exposes schemas and runnable examples. Still, it is not suitable when policy requires a different provider credential for every capability, or when the organization already has a gateway and secrets platform it operates well. Stick with Kong in a Kong-centered edge, AWS API Gateway in an AWS-native edge, or a dedicated key product when key management is the product boundary rather than one part of onboarding.
I'm not sure which dedicated option wins for a workload without its traffic pattern, operator model, and recovery target. A small failure drill resolves that uncertainty: interrupt the client after issuance but before it reads the response, retry the same logical signup, and verify that the only recovery path exposed to the tenant is rotation. Run the same drill against each candidate. Measure steps, not slideware.
The final rule is compact: one tenant, one credential boundary, one plaintext handoff. Consolidation can remove SDK and billing clutter, but it doesn't excuse sharing a secret across merchants.
References
- https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- https://www.unkey.com/docs
- https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-usage-plans.html
- https://developer.konghq.com/plugins/key-auth/
- https://tyk.io/docs/basic-config-and-security/security/authentication-authorization/
Top comments (0)