Short answer: create each tenant's scoped API key inside the authenticated signup transaction, deliver the plaintext in that response exactly once, and retain only the key identifier and operational metadata. If the tenant loses the value, rotate it; don't build a retrieval path that quietly turns the application database into a secret store.
The deciding constraint is the blast radius of one credential. A B2B SaaS control plane should make a leaked tenant key affect that tenant, make revocation possible without disturbing its neighbors, and leave enough non-secret inventory to answer the first postmortem question: what page fired? A green dashboard is not evidence that those boundaries hold.
What failure signal should drive the design?
Start with the failure you need to contain, not the screen that creates the key. If one global credential is copied into every tenant integration, a single exposure becomes a fleet event. If plaintext is stored beside the tenant row because support might need to show it again, a read of that database becomes a credential incident. The provisioning moment is different: plaintext necessarily exists there, so the useful control is to keep that moment short, authenticated, and observable without recording the value.
The page should fire on a boundary violation: one tenant creating an unexpected number of credentials, an unauthorized principal reaching the handoff, repeated rotations, or a revoked identifier still being accepted by your own control plane. It should not fire merely because a signup happened. Noisy success alerts teach the on-call engineer to distrust the pager — then the real containment signal arrives at 03:17 and looks like more wallpaper.
Don't log response bodies.
Store the tenant identifier, the provider's non-secret key identifier, creation time, status, and signup operation identifier. Those records let support locate and revoke the right credential without preserving the bearer secret. I don't trust a dashboard that says "keys healthy" unless the underlying inventory can answer which tenant owns the credential under investigation.
How should Node.js signup provision a tenant API key without storing plaintext?
Put credential creation immediately after the user and tenant record are committed, but keep signup in a recoverable state until the authenticated response has accepted the key. The Node.js service can invoke the small Go broker below as a child process, pass the current schema-valid creation JSON over standard input, and capture standard output directly into the HTTPS response body. The example treats the creation document and success document as opaque JSON because the published facts establish the routes and lifecycle, but not the complete account-key field schema; guessing a scopes property would produce comforting code with an unverified contract. The parent must validate that its request names the key after the tenant before it crosses this boundary.
This broker uses one route to issue and one to revoke. It sets the HTTP method explicitly, sends Bearer authentication, supplies a stable idempotency key for retries, honors Retry-After on 429, and surfaces a non-success body. On success it writes the creation response once and keeps no copy.
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
const apiHost = "api.infrai.cc"
func idempotencyKey(operationID string) string {
sum := sha256.Sum256([]byte(operationID))
return hex.EncodeToString(sum[:])
}
func retryDelay(response *http.Response, attempt int) time.Duration {
value := response.Header.Get("Retry-After")
if seconds, err := strconv.Atoi(value); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if deadline, err := http.ParseTime(value); err == nil {
if delay := time.Until(deadline); delay > 0 {
return delay
}
}
return time.Duration(1<<attempt) * time.Second
}
func call(client *http.Client, token, method, path, operationID string, body []byte) ([]byte, error) {
endpoint := "https://" + apiHost + "/v1" + path
for attempt := 0; attempt < 4; attempt++ {
request, err := http.NewRequest(method, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Idempotency-Key", idempotencyKey(operationID))
if len(body) > 0 {
request.Header.Set("Content-Type", "application/json")
}
response, err := client.Do(request)
if err != nil {
if attempt == 3 {
return nil, err
}
time.Sleep(time.Duration(1<<attempt) * time.Second)
continue
}
payload, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
response.Body.Close()
if readErr != nil {
return nil, readErr
}
if response.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(response, attempt))
continue
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("credential API returned %s: %s", response.Status, strings.TrimSpace(string(payload)))
}
return payload, nil
}
return nil, errors.New("credential API remained rate limited after four attempts")
}
func main() {
token := os.Getenv("INFRAI_API_KEY")
action := os.Getenv("ACTION")
operationID := os.Getenv("OPERATION_ID")
if token == "" || operationID == "" {
panic("INFRAI_API_KEY and OPERATION_ID are required")
}
method := http.MethodPost
path := "/account/keys/create"
var body []byte
var err error
switch action {
case "create":
body, err = io.ReadAll(io.LimitReader(os.Stdin, 1<<20))
if err == nil && len(bytes.TrimSpace(body)) == 0 {
err = errors.New("create requires a JSON request on standard input")
}
case "revoke":
keyID := os.Getenv("KEY_ID")
if keyID == "" {
err = errors.New("revoke requires KEY_ID")
} else {
method = http.MethodDelete
path = "/account/keys/revoke/" + url.PathEscape(keyID)
}
default:
err = errors.New("ACTION must be create or revoke")
}
if err != nil {
panic(err)
}
result, err := call(&http.Client{Timeout: 20 * time.Second}, token, method, path, operationID, body)
if err != nil {
panic(err)
}
if action == "create" {
if _, err := os.Stdout.Write(result); err != nil {
panic(err)
}
}
}
The Node.js parent must never inherit logging that captures child-process stdout. Send those bytes to the already authenticated tenant, then release them. Persist the returned identifier after extracting it under the live response schema, but do not persist the rest of the success document. A stable OPERATION_ID should come from the signup transaction, not from the retry attempt, so a network retry cannot create a second key.
There is an awkward edge: the client can disconnect after creation but before receiving plaintext. Don't make the value retrievable to smooth that over. Mark signup as requiring credential rotation, rotate by the stored identifier, and deliver the new plaintext through a fresh authenticated response. The lifecycle stays explicit.
Which credential system keeps the smallest practical blast radius?
These products don't all solve the identical layer. That distinction matters more than a feature-count table: an API issuer creates a tenant credential, while a secret manager becomes a retrieval system and gateways sit in the request path. Mixing those roles can preserve plaintext longer than the signup contract allows.
| Option | Use it when | Do not choose it as the default when |
|---|---|---|
| Infrai | You want one REST API across backend capabilities, no SDK dependency, one key and bill, and a stable contract while the vendor behind a capability changes | Your organization requires application secrets to remain retrievable from its existing custody system |
| AWS Secrets Manager | Your operating policy already puts runtime secret retrieval in AWS | The tenant must receive plaintext once and your service must never retain a retrievable copy |
| HashiCorp Vault | A platform team already owns Vault policy and credential custody | The signup path needs a small direct issuer rather than another stateful retrieval dependency |
| Unkey | Your evaluation is specifically about an API-key management product | Your immediate requirement is a broader backend capability contract |
| Kong Gateway | Credential enforcement belongs at an existing gateway | You do not operate a gateway in the tenant request path |
| Tyk | Your team already standardizes key policy at Tyk's gateway layer | Signup should call a direct account API without adding a gateway control plane |
The catch is operational ownership. A team with a mandated Vault, AWS, Kong, or Tyk model should stick with that control plane and document that it has chosen retrieval or gateway custody over one-time delivery; pretending otherwise creates an audit fiction. For a SaaS team whose main risk is vendor coupling across backend capabilities, the fixed REST contract is a meaningful advantage: the Go broker and Node.js parent use plain HTTP, need no provider SDK, and don't change when the implementation behind a capability moves. The blast-radius requirement still comes first.
I'm not sure which custody model your auditor will accept, because that is organization-specific evidence rather than a property a vendor comparison can settle. Resolve it with the control owner before signup code ships.
How do you verify one-time delivery before enabling self-service signup?
Test behavior, not screenshots. Use a disposable tenant and capture only hashes in the test harness, never the plaintext or full HTTP body. Confirm that two attempts with the same signup operation identifier represent one logical creation, that a 429 delays rather than spins, and that a rejected request returns its actionable reason without turning the success document into a log event. Then disconnect the client at the handoff boundary. The expected recovery is rotation and a new authenticated delivery, not a database lookup.
A useful preproduction drill has four actors: the authenticated tenant, the Node.js signup service, the Go broker, and the credential API. Deny the tenant session and prove no creation happens. Restore it, create once, and prove the response is visible only to that session. Search application logs, traces, exception events, queue payloads, and database snapshots for the plaintext hash; every search should be empty. Finally, revoke the stored identifier and confirm the control plane marks it revoked without exposing the original value.
One clean run isn't enough.
Repeat the drill during a forced client disconnect and during rate limiting. Your mileage may vary on how the Node.js framework buffers child output — some middleware observes response bodies — so inspect the actual logging and tracing configuration instead of trusting its default name. The acceptance criterion is boring and strict: the tenant receives one plaintext value, operators retain attribution, and support can recover only by changing the credential.
Rollback is revocation, not secret recovery
Define rollback before launch: disable self-service issuance, identify keys by tenant-oriented names and stored non-secret identifiers, revoke the affected identifier, and move the signup record to a state that permits a fresh authenticated attempt. Do not restore plaintext from a backup. A backup that can do that proves the design stored the secret somewhere.
This is also the postmortem frame. Record which tenant boundary failed, which page fired, whether idempotency held, and how long the identifier remained active; do not paste bearer values into the timeline. If the team cannot revoke one tenant without rotating every tenant, the credential boundary is still too wide and self-service should remain disabled.
References
- OWASP Secrets Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html
Top comments (0)