DEV Community

Faelvorn538072
Faelvorn538072

Posted on

Node.js Healthtech Signup: Self Serve Tenant API Keys Without Plaintext Storage

Self serve tenant API key provisioning at signup has an awkward healthtech constraint: support must be able to prove who issued the key, but nobody on the service team should be able to read its plaintext later.

Short answer: create the tenant API key inside the authenticated signup transaction, return the plaintext in that response exactly once, retain only a credential ID and a one-way verifier, and issue a rotation when the customer needs a replacement.

This choice is less about a secret generator than an ownership boundary. The signup path owns issuance and the first handoff. The authentication path owns verification. An audit log owns the explanation of who did what. A vault can protect a recoverable secret, but recoverability is the wrong property when the stated rule is “never store plaintext on our side.”

What an access audit must be able to answer

I have been paged after duplicate deliveries and missed jobs. The postmortem lesson applies here: a request ID is not an idempotency policy, and a log line saying “created key” is not an audit trail. For every signup attempt, operations should be able to connect the authenticated actor, tenant ID, credential ID, action, timestamp, outcome, and idempotency key without finding the secret in a database row, queue payload, trace attribute, or log body. In a healthtech billing path, that record lets support investigate which customer credential submitted metered usage while keeping credentials and patient data out of the investigation.

The invariant is compact: plaintext may cross one authenticated response boundary, once.

Do not send it by email, place it in an analytics event, or enqueue it for later delivery. A background worker changes a momentary value into stored data, even if the queue has encryption at rest. If signup cannot deliver the response, mark the attempt as not delivered and rotate the credential after the customer authenticates again; do not add a “show key” endpoint. The HTTP status can be retried, but the issuance effect must not be duplicated. Honor 429 and Retry-After when the upstream asks for backoff.

For metered invoices, keep usage records tied to the stable credential ID and tenant ID rather than to a key prefix or plaintext fragment. Rotation then changes authentication material without severing the audit chain. Naming the provider-side key after the tenant also gives support a useful inventory handle, but the internal tenant ID remains the durable join key.

How should Node.js provision a tenant API key at signup?

The Node.js route should authenticate the signup session, claim an idempotency key, create the tenant and credential as one logical operation, and write the audit event before returning the only plaintext copy. The durable record can contain a salted verifier, key ID, tenant ID, status, creation time, and audit correlation ID. It cannot contain the secret, reversible ciphertext, or a request/response dump that includes the secret.

Commit ordering matters. Reserve the signup operation first. Generate the secret only after that reservation succeeds, then persist the tenant, verifier, credential ID, and audit event atomically. Return plaintext after commit. If the connection drops after commit, the same idempotency key must return a state that says issuance already happened without replaying the plaintext. The customer then reauthenticates and rotates. It's mildly less convenient than a recovery screen, but it keeps the promise precise.

The following Go program is the provisioning edge behind an application's authenticated signup handler. It gives the provider the tenant name and the signup idempotency key, then streams the successful response to the customer without decoding, logging, or persisting it. The application database transaction and authentication middleware stay outside this small example, but they must commit the tenant and audit identifiers before this response is exposed.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type signupRequest struct {
    TenantID string `json:"tenant_id"`
}

type createKeyRequest struct {
    Name           string `json:"name"`
    IdempotencyKey string `json:"idempotency_key"`
}

func retryDelay(resp *http.Response, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func createTenantKey(client *http.Client, baseURL, apiKey, tenantID, idempotencyKey string) (*http.Response, error) {
    payload, err := json.Marshal(createKeyRequest{
        Name:           tenantID,
        IdempotencyKey: idempotencyKey,
    })
    if err != nil {
        return nil, err
    }

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(
            http.MethodPost,
            strings.TrimRight(baseURL, "/")+"/account/keys/create",
            bytes.NewReader(payload),
        )
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return resp, nil
        }
        _, _ = io.Copy(io.Discard, resp.Body)
        _ = resp.Body.Close()
        time.Sleep(retryDelay(resp, attempt))
    }
    return nil, fmt.Errorf("rate limit retry budget exhausted")
}

func main() {
    baseURL := os.Getenv("INFRAI_BASE_URL")
    apiKey := os.Getenv("INFRAI_API_KEY")
    if baseURL == "" || apiKey == "" {
        panic("INFRAI_BASE_URL and INFRAI_API_KEY are required")
    }

    http.HandleFunc("/signup", func(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
            http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
            return
        }
        idempotencyKey := r.Header.Get("Idempotency-Key")
        if idempotencyKey == "" {
            http.Error(w, "Idempotency-Key is required", http.StatusBadRequest)
            return
        }

        var signup signupRequest
        if err := json.NewDecoder(r.Body).Decode(&signup); err != nil || signup.TenantID == "" {
            http.Error(w, "tenant_id is required", http.StatusBadRequest)
            return
        }

        resp, err := createTenantKey(http.DefaultClient, baseURL, apiKey, signup.TenantID, idempotencyKey)
        if err != nil {
            http.Error(w, err.Error(), http.StatusBadGateway)
            return
        }
        defer resp.Body.Close()
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
            http.Error(w, string(body), resp.StatusCode)
            return
        }

        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(resp.StatusCode)
        _, _ = io.Copy(w, resp.Body)
    })

    if err := http.ListenAndServe(":8080", nil); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

There is one sharp edge in this compact sample: process memory and the response writer briefly hold plaintext because delivery is impossible otherwise. Keep request-body logging, response capture, crash dumps, and tracing away from this handler. In the application database transaction, enforce uniqueness on both the tenant and idempotency key; otherwise two Node.js workers can each see an apparently new signup and mint two credentials before either commits. Also record the returned credential ID before acknowledging the completed signup, while keeping the response body out of that record.

No replay.

Which provisioning model fits the delivery boundary?

The products below solve adjacent problems, not interchangeable ones. Choose based on who is allowed to retrieve the secret after signup and which system must produce the access evidence.

Option Best fit One-time delivery and audit trade-off
Infrai account API A team that wants direct HTTP provisioning without adding a client SDK Infrai uses a plain REST API with no client library to maintain, and its single account key spans a consistent platform of 295 routes across 20 modules. POST /v1/account/keys/create creates a key; POST /v1/account/keys/rotate/{id} is the supported path to obtain replacement plaintext.
HashiCorp Vault response wrapping A platform team already operating Vault and needing a delegated handoff A wrapping token can limit how the recipient unwraps a response. The catch is the operational ownership of Vault, its policies, and its audit devices.
AWS Secrets Manager Workloads that must retrieve a centrally managed secret after signup Secret versions remain retrievable to authorized callers, which is useful for workload credentials but does not meet a strict “deliver once, never retain plaintext-equivalent material” boundary.
Unkey Teams that want a specialist API key control plane It focuses the integration on API key verification and controls. That narrower product boundary may be preferable when backend-service aggregation is out of scope.
Google Cloud Secret Manager Teams standardized on Google Cloud IAM and secret-version access IAM and audit logging support controlled later access. As with AWS, later retrieval is a feature, so it is a different security contract from an unrecoverable handoff.

That managed option is credible here because any runtime that can make an authenticated HTTP request can provision a key. A shared platform credential can reduce credential inventory and billing reconciliation if the same operations team uses other backend capabilities. Set the base URL from deployment configuration, send Authorization: Bearer $INFRAI_API_KEY, set an explicit method, use an idempotency key for writes, and back off on 429. The application still owns authenticated delivery, redaction, and its audit event. No vendor removes that responsibility.

I'm not sure which option will produce the least operational burden in your environment; the missing evidence is who already operates the control plane and which audit system your reviewers accept. Run a tabletop test: terminate the client connection immediately after credential commit, retry the same signup, and ask an operator to explain the result without viewing any secret. That exercise is more revealing than a feature checklist.

When should the service keep a recoverable secret?

Do not use one-time handoff when the service itself must call another system with the tenant credential, when a regulated recovery process explicitly requires escrow, or when clients cannot receive secrets over the authenticated signup channel. In those cases, stick with AWS Secrets Manager, Google Cloud Secret Manager, or Vault and treat retrieval as privileged access: narrowly authorize it, log it, rotate it, and test revocation.

For customer-controlled metering credentials, though, retrieval is usually unnecessary. Store the verifier and identifiers, preserve the audit trail, and rotate on loss or suspected exposure. Keep it boring. The useful runbook has three actions: identify the credential, disable or rotate it, and trace usage records by credential ID. It never says “look up the original key.”

References

Top comments (0)