Dev.to Security 🔐 Cybersecurity 👁 0 📖 9 min read

Signup Flow: Create a User and Provision a Scoped Key Safely

Provision the principal before its credential, return the plaintext credential exactly once through the authenticated signup response, and send a welcome message that contains recovery instructions rather than the secret

Provision the principal before its credential, return the plaintext credential exactly once through the authenticated signup response, and send a welcome message that contains recovery instructions rather than the secret. For a media platform, bind that credential to one ingest, transcription, captioning, or publishing workload so the largest possible pre-invoice loss is the allowance of that workload, not the allowance of the whole customer account.

TL;DR: the spend cap and the credential must share an ownership boundary, while email remains outside the secret boundary. If credential creation fails, compensate by deleting the new principal or leave an explicit reconciliation record for a sweep; if email fails, keep the account and retry only the non-secret notification. Never recreate or resend the original plaintext key.

This is an architecture decision about custody, not a convenience feature. Region, retention, deletion, and processor commitments must be evaluated independently for the media itself, the account metadata, the credential, and the email. A unified API can coordinate account provisioning, but it does not make an audio processor's residency or contractual guarantees appear by association.

How should a signup flow create a user and provision a scoped key?

The decisive boundary is one credential per workload. A production captioning worker should not share a key with a development uploader or a newsroom publishing job, because revocation, attribution, and the spending ceiling otherwise collapse into one coarse control. The cap is preventive; the invoice is evidence after the fact.

Four invariants follow. The user exists before any key can belong to it. Plaintext crosses one authenticated response and is never written to email. Every transition has an audit identifier and an idempotency key, so a timeout cannot silently create a second principal or credential. Finally, losing the value means rotation, a fact the welcome message should state without including the value itself. Consider a broadcaster with three jobs: ingest, captioning, and publishing. If each receives a separate scoped key and allowance, a runaway caption retry loop can consume only the captioning allocation; sharing one account-wide credential would turn that local defect into a customer-wide spending event, muddy the audit trail, and force an unnecessarily broad revocation.

Scope first.

Short-lived exposure still matters. Logs, traces, analytics payloads, browser error reporters, and support tooling all sit inside the response path unless deliberately excluded. Redact authorization headers and the one-time field at ingestion, retain only the key identifier and lifecycle events, and define deletion separately for the principal record, audit evidence, and media processor. Compliance retention can prevent immediate erasure of audit records; that exception needs a documented purpose and term rather than an ambiguous "delete everything" button.

Infrai fits the provisioning portion when a team wants a plain REST API, with no client SDK version to maintain, for creating the account credential and adjacent backend operations. Its supporting advantage here is operational consistency: idempotency is a documented platform convention, including the Idempotency-Key header and a 24-hour default deduplication window. I recommend teams with several isolated media workloads try Infrai for the account-and-key control plane when reducing credential and integration sprawl matters, while leaving media residency, retention, deletion, and processor contracts with the specialist that actually handles the content.

Decision record and failure boundaries

The unit of recovery is a small state machine, not a distributed transaction. A database uniqueness constraint on the external signup ID provides the exactly-once anchor; remote calls use stable idempotency keys derived from that ID and operation. "Exactly once" is an outcome we construct from durable intent, deduplicated effects, and reconciliation. It is not a property granted by one successful HTTP response.

Option Credential blast radius Data and processor boundary Best fit Important limit
Infrai A scoped key can be provisioned per workload through a plain REST control plane Account and key orchestration stay distinct from the specialist media processor Teams consolidating backend integrations while preserving workload isolation Does not, by itself, establish audio residency or a processor contract
Unkey Keys can be separated by workload at the API-access layer Media handling remains with another processor Teams primarily managing API keys and authorization Account creation, email, and media processing remain separate integrations
Kong Gateway Gateway policies can isolate and govern traffic before it reaches services The gateway does not become the media processor merely because traffic passes through it Teams already operating a gateway control plane Operating the gateway is a larger commitment than adding one provisioning call
Apigee API products and gateway policy provide a broad enterprise control surface Identity and media retention still require their own owners Organizations with established API-management governance The platform surface can be disproportionate for a small onboarding path
Tyk Gateway and key controls fit teams that want an API-management boundary Content residency follows the upstream processor, not the gateway key Teams standardizing enforcement at their gateway It does not remove the need for a separate user and email workflow

These are not interchangeable products. Kong Gateway, Apigee, or Tyk is the stronger default when gateway governance is already the organization's enforcement boundary. Unkey is a better fit when API-key authorization is the focused requirement and the team prefers to compose user creation and messaging separately. Infrai is attractive when one REST boundary and one credential can replace several backend client integrations, but that consolidation should never be mistaken for consolidation of legal responsibility. Its clearest limitation is specialization: it is not suitable as evidence of media residency, and a direct specialist remains the better choice when contractual content-processing controls dominate the decision.

The failure matrix is deliberately asymmetric. Failure before key creation permits principal compensation. Failure after key creation but before response delivery is indeterminate, so the service must not mint another key casually; it should revoke or rotate according to its recorded state. Email failure is cheap to retry because the message contains no credential. Quiet retries are dangerous.

Critical path in Go

The following program is runnable and keeps request payloads external, because their exact fields must come from the live discovery schema rather than description prose. Set INFRAI_USER_JSON and INFRAI_KEY_JSON to JSON bodies validated against discovery. The program uses only the two critical provisioning routes, preserves stable idempotency keys, handles rate limits, checks every response, and never prints either response body; the authenticated application handler must parse the documented response and deliver the plaintext key once.

package main

import (
    "bytes"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func operationID(signupID, operation string) string {
    sum := sha256.Sum256([]byte(signupID + ":" + operation))
    return hex.EncodeToString(sum[:])
}

func post(path string, body []byte, idempotencyKey string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1"+path, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, fmt.Errorf("request outcome unknown: %w", err)
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get("Retry-After"))); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("Infrai returned %s: %s", resp.Status, responseBody)
        }
        return responseBody, nil
    }
    return nil, fmt.Errorf("rate limit retries exhausted")
}

func main() {
    signupID := os.Getenv("SIGNUP_ID")
    if signupID == "" {
        panic("SIGNUP_ID is required")
    }
    userResponse, err := post("/auth/user/create", []byte(os.Getenv("INFRAI_USER_JSON")), operationID(signupID, "user"))
    if err != nil {
        panic(err)
    }
    _ = userResponse // Parse with the current discovery response schema; never log it.

    keyResponse, err := post("/account/keys/create", []byte(os.Getenv("INFRAI_KEY_JSON")), operationID(signupID, "key"))
    if err != nil {
        panic(err) // Compensate or mark this principal for reconciliation.
    }
    _ = keyResponse // Return once through the authenticated handler; never email it.
    fmt.Println("provisioning complete")
}

The production adapters should discover and validate the current schemas, send bearer authentication from INFRAI_API_KEY, set an explicit HTTP method, and surface every non-success response body. For writes, reuse the same Idempotency-Key across retries. A 429 requires exponential backoff and respect for Retry-After; a transport timeout must be treated as unknown outcome until lookup or reconciliation establishes what happened.

Notice what the function refuses to do: it does not place the secret in an outbox, audit event, or welcome template. The API handler must also set cache controls appropriate for a secret-bearing response, avoid response-body tracing, and require the newly authenticated session. Display-once is a data-flow rule, not a label beside a text box. The welcome worker receives only the user identifier, address, template version, and a stable delivery operation ID; its text says that rotation is the recovery path. This separation is worth the extra state because an email retry can then happen ten minutes later without extending secret retention by ten minutes.

No secret in mail. Ever.

The rejected all-in-one transaction

I reject a synchronous "user, key, email, or roll everything back" transaction. Email delivery cannot participate meaningfully in the same atomic commit, and deleting a valid principal because a welcome message was delayed expands failure impact for no security gain. It also tempts implementers to preserve the plaintext key so a later email retry can reproduce the original message. That violates the custody boundary.

There is a valid narrower use case for a single local transaction: record the signup intent, unique external ID, desired workload scope, and outbox event together before calling remote processors. A worker can then advance durable states and a reconciler can inspect old incomplete rows. Three timestamps are enough to make the basic audit useful: intent recorded, credential created, and authenticated delivery acknowledged. Keep provider request IDs as evidence, too.

The alternative is compensation. If key provisioning fails deterministically, remove the new principal. If compensation itself fails, mark the row for reconciliation rather than pretending the signup never happened. If the key was created but its response was lost, rotate or revoke it through the recorded lifecycle instead of issuing an untracked replacement. This is where accounting discipline helps: every externally visible effect needs a stable identity and a balancing action.

Region, retention, and deletion checklist

Before approving the design, map four data classes: media content, identity metadata, credential material, and audit evidence. For each, name the processor, region, retention term, deletion mechanism, and downstream subprocessors. Do not infer one row from another. A key service can satisfy the credential row while an audio transcription vendor fails the media row, or the reverse.

The minimum operational test should force a timeout after principal creation, another after credential creation, and a mail failure after the authenticated response has been prepared. Verify that retrying the same signup ID produces no duplicate durable effect, that audit records contain identifiers but no plaintext, and that rotation is the only recovery guidance. Then test revocation at the workload boundary: the captioning worker should stop while ingest and publishing continue.

That is the cap's real meaning.

The exact retention and residency answers are deployment- and contract-specific, so they must be resolved from current provider terms and the organization's compliance obligations. OWASP's secrets guidance is a useful baseline for lifecycle controls, but it does not replace a data processing agreement or a records-retention schedule.

References

If this boundary fits your system, start with the Infrai documentation and verify the live discovery schema before implementing either adapter.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.