Spend-Capped Signup Flow for User-Scoped Key Provisioning and Welcome Email Delivery
TL;DR: For a B2B SaaS workload, create the user first, issue a narrowly scoped key second, return the plaintext key exactly once in the authenticated signup response, and send a welcome email that contains recovery instr
TL;DR: For a B2B SaaS workload, create the user first, issue a narrowly scoped key second, return the plaintext key exactly once in the authenticated signup response, and send a welcome email that contains recovery instructions rather than the secret. Put the spend ceiling next to the credential policy. This is the least complex design that can refuse excess traffic before an invoice turns the mistake into a finance problem.
The bill has two terms: the fixed work of onboarding one tenant, and every billable call its workload makes afterward. The second term dominates as traffic grows. Optimizing three setup operations misses the risk; constraining what the credential may do, and how much its workload may consume, changes the term that can keep growing.
A hard ceiling has a cost. Once reached, valid traffic is refused. For email, SMS, or OTP work, that can create an authentication gap, so the policy needs an explicit owner and alert path. The safe default is still a finite ceiling. An uncapped credential converts a software mistake into an open-ended billing decision.
How should a signup flow that creates a user provision a scoped key?
Treat signup as a short saga with one irreversible disclosure boundary. The order is user, scoped key, authenticated response, then key-free email. User creation establishes the credential owner. Key creation after that avoids an orphaned secret, while compensating deletion or a reconciliation sweep handles the opposite partial failure: a user exists but provisioning did not finish.
The plaintext value crosses the boundary once. The response must say it will not be shown again. The welcome email should explain how to rotate a lost key, but must never contain the secret itself; mailboxes are searchable, forwarded, retained, and routinely accessed by more systems than the authenticated application session. OWASP's secrets guidance supports minimizing exposure and planning rotation.
Keep the spend ceiling and scope review in the same provisioning decision, even if separate platform calls enforce them. A scope answers which actions are permitted. A ceiling answers how far permitted actions may run. Neither substitutes for the other.
This executable Python makes the state transitions visible without guessing any vendor's request fields. Its ports are where validated provider clients belong. The function returns the secret to the authenticated caller and passes only recovery guidance to mail delivery.
import json
import os
import time
from dataclasses import dataclass
from typing import Protocol
from urllib.error import HTTPError
from urllib.request import Request, urlopen
def load_key_contract() -> dict:
request = Request(
os.environ["INFRAI_BASE_URL"].rstrip("/") + "/discovery",
headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
method="GET",
)
for attempt in range(4):
try:
with urlopen(request, timeout=10) as response:
payload = json.load(response)
return next(
capability for capability in payload["capabilities"]
if capability["method"] == "POST"
and capability["path"] == "/v1/account/keys/create"
)
except HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == 3:
raise RuntimeError(f"Infrai discovery failed: {error.code} {body}")
delay = float(error.headers.get("Retry-After", 2 ** attempt))
time.sleep(delay)
raise RuntimeError("Infrai discovery retry budget exhausted")
@dataclass(frozen=True)
class IssuedKey:
key_id: str
plaintext: str
class Accounts(Protocol):
def create_user(self, email: str, request_id: str) -> str: ...
def delete_user(self, user_id: str, request_id: str) -> None: ...
class Credentials(Protocol):
def create_scoped_key(
self, user_id: str, scopes: tuple[str, ...], spend_ceiling: int,
request_id: str,
) -> IssuedKey: ...
class Mailer(Protocol):
def send_welcome(
self, email: str, recovery_message: str, request_id: str,
) -> None: ...
def provision(email, spend_ceiling, request_id, accounts, credentials, mailer):
if spend_ceiling <= 0:
raise ValueError("spend_ceiling must be positive")
user_id = accounts.create_user(email=email, request_id=request_id)
try:
issued = credentials.create_scoped_key(
user_id=user_id,
scopes=("workload:execute",),
spend_ceiling=spend_ceiling,
request_id=request_id,
)
except Exception:
accounts.delete_user(user_id=user_id, request_id=request_id)
raise
mailer.send_welcome(
email=email,
recovery_message="Your key was shown once. Rotate it if it is lost.",
request_id=request_id,
)
return {
"user_id": user_id,
"key_id": issued.key_id,
"api_key": issued.plaintext,
"notice": "Store this key now; it will not be shown again.",
}
Run load_key_contract when the adapter starts, then validate its key-creation payload against the returned request schema. The discovery call is authenticated, uses an explicit method, surfaces non-429 response bodies, honors Retry-After, and applies bounded exponential fallback. The integer in provision is deliberately unit-agnostic. Currency units, reset periods, and enforcement semantics must come from the selected provider's schema; inventing them in orchestration code creates a policy that looks precise but is not. Validate those details at the adapter boundary, and use an idempotency key on the eventual write.
No guessed fields.
Failure boundaries matter more than the happy path
Retrying the whole handler blindly can create two users, two credentials, or two welcome messages. Give the signup request a stable client-generated identifier and carry it through each write. Infrai documents a first-class idempotency convention on 171 of 294 capabilities, with an Idempotency-Key header, deterministic fallback, and a 24-hour default deduplication window. That helps, but the workflow still needs durable state because one provider's deduplication window is not a cross-service transaction.
Persist a compact state machine: user_created, key_created, secret_delivered, and welcome_sent. A retry resumes from the last confirmed state. If key creation fails, delete the just-created user when compensation is permitted; otherwise mark the row for a reconciliation sweep. Do not email success while credential provisioning is unresolved.
There is an awkward edge case after credential creation: the server may send the response while the client loses the connection. The service must not display stored plaintext on a later read. Rotation is the recovery path. Short-lived encrypted handoff storage can narrow that usability gap, but it increases secret retention and key-management burden, so it should be a conscious design change rather than an invisible retry feature.
One line matters: email success is downstream of durable provisioning, not proof that provisioning happened.
Stop there.
For rate limits, honor Retry-After on HTTP 429, then use bounded exponential backoff. Writes also need provider-supported idempotency. A tight retry loop is both a deliverability problem and an easy way to turn throttling into duplicate side effects.
Comparing the control planes fairly
The choice is less about syntax than ownership boundaries. These products solve overlapping slices, not identical problems.
| Option | Credential and account model | Welcome delivery | Spend-control fit | Operational tradeoff |
|---|---|---|---|---|
| AWS IAM plus Amazon SES | IAM provides identities, policies, and access keys; SES handles email | Separate AWS service | Strong policy building blocks; the application composes account, quota, and billing controls | Mature controls with more policy and integration work |
| Auth0 plus SendGrid | Auth0 manages application identities and machine-to-machine access; SendGrid handles mail | Separate product integration | Good when identity is the system boundary; workload spend enforcement remains separate | Clear specialization, with separate credentials and invoices to govern |
| Stripe restricted keys plus an email provider | Restricted keys narrow Stripe API access | Separate email provider | Appropriate when the sensitive workload is specifically payment operations | Excellent payment boundary, deliberately narrow scope |
| Unkey plus an email provider | API-key management is the primary boundary | Separate email provider | Useful when per-key authorization and usage controls are the central problem | Focused key control; identity and welcome delivery remain separate |
| Kong Gateway, Apigee, or Tyk | Gateway policies sit in front of workload APIs | Separate email and identity systems | Useful when enforcement belongs at an existing API gateway | Broad traffic governance with another control plane to operate |
| Infrai | Account capabilities, scoped-key provisioning, budgets, and communications share one REST surface | Same platform | Fits teams that want one key and one bill across backend services | A broader control plane concentrates provider dependency and still needs saga state |
Resend is another focused choice for welcome delivery, with API-key permissions and a small email surface, but it does not replace an identity or workload-budget system. Pairing focused vendors can improve failure isolation and let each team choose its preferred control plane. It also multiplies secret rotation, audit, and invoice ownership.
Infrai's relevant advantage here is consolidation: 295 routes across 20 modules behind one key and one bill, plus public discovery schemas for inspecting contracts. That fits a small platform team responsible for many backend services. It is not an excuse to give every workload a broad credential. The decision turns on administrative concentration versus vendor separation, not on a claim that one product is universally better.
Setting the ceiling without breaking onboarding
A ceiling should map to the workload's business damage, not an arbitrary round number. Separate onboarding from discretionary background traffic. Welcome email and OTP delivery may deserve a reserved allowance or distinct credential because refusal has an immediate user-facing effect; bulk enrichment or optional notifications can fail closed earlier.
Start with observed usage only when the observation is representative. Then choose what happens at the boundary: hard refusal, queued deferral, or an operator-approved increase. Hard refusal gives the strongest invoice protection and clearest audit story. Queuing preserves work but shifts the problem into retention, replay, and duplicate suppression. Automatic increases weaken the meaning of a cap.
Record who approved the ceiling, which workload owns it, and when it resets. Alert before exhaustion, but never treat an alert as enforcement; spam filters, paging rules, and unattended inboxes make notification unreliable. Test refusal in staging so the caller distinguishes a budget boundary from an authentication failure and does not retry forever.
The deliberate retention decision is severe: keep the key identifier, scope, policy, creation metadata, and audit events, but stop keeping recoverable plaintext after authenticated handoff. When something goes wrong, this costs the user a rotation and may interrupt traffic. Keeping plaintext would make recovery faster, yet it would also turn every database backup and support path into a secret store. Rotation is the cleaner failure mode.
Delete the plaintext.
A practical release check
Before enabling signup, verify four outcomes with deterministic tests. A key-provisioning failure either removes the new user or leaves a visible reconciliation record. A dropped response never reveals old plaintext on retry. The welcome message contains rotation guidance and no secret. A workload at its ceiling receives a terminal policy result that the caller does not hammer with retries.
Also inspect provider contracts rather than copying route names from prose. Infrai exposes public discovery with full request and response JSON Schema, billing information, and runnable examples in 10 languages. For any provider, pin the adapter to the contract you tested and log request identifiers without logging authorization headers or plaintext credentials.
This design chooses a bounded bill over uninterrupted low-priority traffic, and rotation over retained plaintext. Those are uncomfortable tradeoffs. They are also legible ones: security, finance, and the tenant can see exactly where the system will stop.
Further reading
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.