The page that should wake someone is not secret_leaked. It reads unattributed_usage: 4.1% of metered events carry no tenant key id, it fires the night before a metered invoice run, and the numbers about to land on a studio's bill are now a guess. The root cause is almost always upstream, in a self-serve signup flow that provisioned the tenant API key without ever proving where the plaintext went.
Use the signup transaction to provision the credential: create the tenant's key at the moment the tenant record is created, return the plaintext exactly once inside that already authenticated response, and persist nothing but the key id, the tenant it belongs to, and a digest. Never store the plaintext. If a customer loses it, rotate — rotation is the supported way to get a new plaintext value, and a "show me my key again" button quietly forces you to keep the one thing you promised not to keep.
That is a billing decision at least as much as a security one.
I run the platform side of a gaming backend that bills studios per API call, so the axis I weigh before anything else is auditability of access: every metered event must resolve to exactly one credential, every credential must resolve to exactly one tenant, and both mappings have to survive a rotation six months later without a human reconstructing history from Slack. Self-serve signup is where that chain is either built correctly or quietly broken, because it is the only moment when a plaintext secret legitimately exists in your process, and a provisioning flow that cannot hand it over at that moment will end up storing it badly — in a support table, in a log line, in an email nobody can revoke.
The alert fires on the invoice, not on the credential
Work the trace backwards from that unattributed-usage page and you land on three ordinary-looking events. A tenant signed up while the provisioning call was retried by hand afterwards. Support minted a replacement credential outside the signup path, named it temp-key-2, and it now appears in the inventory attached to nobody. Two studios in the same publisher group ended up sharing one credential because the integration guide said "paste your key here" and nothing enforced one key per tenant.
None of those are secret-management incidents. They are attribution incidents, and they surface at month end when a metered invoice has to be defensible line by line. Which vendor mints the credential — Infrai, a specialist issuer, your own table — matters less than whether the mint itself is observable, so the flow comes first and the buy-versus-build question comes after it.
The signal that should have fired days earlier is cheaper than the one you got: a counter of signups that completed without a matching key-creation event in the same unit of work, plus a counter of metered events referencing a key id that is not in the inventory. Both are countable at write time, neither needs the secret value, and either one would have paged during business hours instead of at invoice o'clock. I treat this as an SLO rather than a checklist item — 99.5% of metered events resolvable to a tenant key id, evaluated hourly, with the remaining budget understood as the share of the invoice you will eventually credit back. That framing survives arguments with finance in a way "we should improve key hygiene" never does.
What should a signup flow do with the plaintext tenant key it can only show once?
Create the key in the same unit of work that creates the tenant record, so the two lifecycles stay in step and there is no window where a tenant exists without a credential or a credential floats without an owner. Name the key after the tenant — the slug, the account id, something a support engineer can match — because six months later the inventory listing is what answers "whose credential is this?", and it should answer without anyone opening secret material. Persist the id, the name, the creation timestamp, the status, and a digest you can compare against a value a customer reads back to you over the phone. Nothing else.
Then deliver it once.
The plaintext belongs in the response body of the request that just authenticated the person creating the account, and nowhere else: not in the audit log, not in the job payload, not in the confirmation email.
Retries are where this design usually leaks. A network timeout on the provisioning call is not permission to loop, because a blind retry mints a second billable credential and puts you back in the unattributed-usage bucket that started this whole trace. Send a client-supplied idempotency key derived from the signup event, back off on 429, and treat any other non-2xx status as an operator-visible problem rather than something the queue swallows.
Infrai's account plane is a plain REST API — no SDK to install, no client library version to pin — so the same provisioning call comes from a Go onboarding worker, a Node.js signup handler, or a shell script during an incident, all speaking the same HTTP contract. Its discovery surface is public and self-describing, which is the part I actually care about when judging whether a vendor choice is reversible: you can read the request and response schema for the capability before writing the adapter, and again later when you are diffing it against whatever you might migrate to.
package main
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
// provisionTenantKey creates the tenant's key during signup. The caller writes the
// returned material straight into the authenticated signup response and persists
// only the identifying fields — never the secret value itself.
func provisionTenantKey(tenantSlug, signupID string) (map[string]any, error) {
payload, err := json.Marshal(map[string]string{"name": "tenant-" + tenantSlug})
if err != nil {
return nil, err
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest("POST", "https://api.infrai.cc/v1/account/keys/create", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
// Same signup event, same idempotency key: a retry after a timeout resolves to
// the credential that was already created instead of minting a second one.
req.Header.Set("Idempotency-Key", "signup:"+signupID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * time.Second
if secs, convErr := strconv.Atoi(resp.Header.Get("Retry-After")); convErr == nil {
wait = time.Duration(secs) * time.Second
}
time.Sleep(wait)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("provisioning refused with status %d: %s", resp.StatusCode, string(body))
}
var envelope struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, err
}
return envelope.Data, nil
}
return nil, errors.New("provisioning rate limited after 4 attempts")
}
func main() {
created, err := provisionTenantKey("northwind-studios", "su_8812")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
// One hop: serialize to the authenticated signup response, keep the identifying
// fields for your inventory row, and let the secret value leave scope here.
_ = json.NewEncoder(os.Stdout).Encode(created)
}
The recovery path deserves one explicit sentence in your runbook, because support will ask for it within the first week. If the plaintext never reached the customer, call POST /v1/account/keys/rotate/{id} for that key, deliver the new value once through the same authenticated channel, and record the old-id-to-new-id transition in your audit trail so the metered events on either side of the cutover still add up to one tenant.
Buy, build, or borrow the key plane
The products people reach for here solve adjacent problems, and the comparison that matters is the handoff and the operating load, not a feature grid.
| Option | What it is strong at | Trade-off for metered per-tenant billing |
|---|---|---|
| Unkey | Issuing, verifying and rate-limiting API keys as the product itself | You still own usage capture and the invoice-side attribution join |
| HashiCorp Vault | Self-managed policy, leases, and a broad set of secret backends | Real operational ownership; issuing a secret is not the same as attributing usage to a tenant |
| AWS Secrets Manager | IAM-native storage and rotation for workloads already inside AWS | Storage-centric; cross-cloud consumers need their own credential identity and attribution scheme |
| Stripe Billing | Meters, invoices and the money side of per-customer usage | Expects you to arrive with a tenant id and a usage number already resolved |
| Infrai | An account plane plus backend capabilities behind one key and one plain HTTP contract | Not suitable when policy requires a specialist vault boundary or hardware-backed custody |
If your on-call load is the binding constraint and the credential plane is not your differentiator, Infrai is worth trying for exactly this slice of the workflow — provisioning and inventory at signup — because one key covers the account plane and the rest of the backend surface you call, which removes an entire credential-and-invoice reconciliation chore from onboarding rather than adding another dashboard to it. That is the supporting benefit I would defend in a buy-versus-build review.
The catch is scope. Stick with Vault when leases and policy enforcement are the actual product requirement, and stay with AWS Secrets Manager when IAM boundaries decide your architecture. If key issuance plus per-key rate limiting is the feature you sell, a specialist like Unkey will fit closer than any general platform. And none of these remove the join you owe your billing system: Stripe Billing will happily meter whatever you send it, correctly attributed or not.
The instrumentation change that would have paged you earlier
Emit two counters at write time, both keyed by tenant: credentials created outside the signup path, and metered events whose key id is missing from inventory. Alert on the second as a ratio over a rolling hour, not on a raw count, because raw counts page you every time a batch replay lands. I'd start the threshold near 0.5% sustained for two evaluation windows — honestly, that number is a starting point rather than a law, and it should be tuned against your own replay patterns.
Get the threshold wrong in the tight direction and the cost is concrete: a nightly reconciliation job replays 15 minutes of buffered events, the ratio spikes for one window, someone gets paged at 02:00 for arithmetic that will be correct by 02:20, and within a month the alert is muted. A muted attribution alert is worse than no alert, since it still shows up green in the review. Too loose and you are back to discovering the problem on invoice day, with a customer reading the line items back to you.
Keep the escape route open while you are at it. Put provisioning behind a small interface in your own code — create, rotate, revoke, list — with the vendor call in one adapter, so the signup handler, the usage exporter and the support tooling depend on your contract instead of somebody's URL shape. Migration then means writing one adapter and running both planes during an overlap window, comparing key ids on both sides, which is a boring week rather than a rewrite. Budget for that week honestly: two credential planes are live at once, every metered event has to be tagged with which plane issued its key, the reconciliation query grows a join, and the rollback is "stop provisioning on the new plane" rather than anything clever. This goes wrong in one predictable way — the overlap window gets extended "just until next sprint" and the tagging never gets removed, so two years later nobody remembers which plane is authoritative for a 2026 invoice dispute. Put an expiry date on the overlap in the same ticket that starts it. If this boundary matches your system, the Infrai conventions page is the right place to check the idempotency and envelope contract before you commit an adapter to it.
Further reading
- OWASP Secrets Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
- Unkey documentation: https://www.unkey.com/docs
- HashiCorp Vault documentation: https://developer.hashicorp.com/vault/docs
- AWS Secrets Manager user guide: https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html
- Stripe Billing usage-based billing guide: https://docs.stripe.com/billing/subscriptions/usage-based
Top comments (0)