DEV Community

PeregrineShaw9645
PeregrineShaw9645

Posted on

Implementing Signup Automation: User Provisioning, Scoped Keys, Welcome Email (2026)

Short answer: make signup a durable workflow that creates the user, issues a least-privilege key, and queues the welcome email only after the account transaction commits. Put a spend ceiling beside the key, and refuse traffic when that ceiling or a dependency budget is exhausted.

For a B2B SaaS onboarding flow, the interesting failure is not a red button. It is a half-created tenant: the database says “active,” the key was never scoped, and an email worker keeps retrying with no record of what happened. I design the flow around an idempotency key and an explicit state machine, so a retry can finish work without minting another credential.

What should a signup flow do before it sends a welcome email?

The request carries an idempotency token from the browser or edge. The service opens a database transaction, inserts the user and tenant, and records a pending key grant. A post-commit worker exchanges that grant for a scoped key, stores only a hash, and emits an email job. The worker can run twice; the unique constraints make the second run harmless.

Here is a small TypeScript example. The repository and queue interfaces are intentionally boring: they are the seams I can test with an in-memory adapter before choosing infrastructure.

type Signup = { tenantId: string; email: string; idempotencyKey: string };
type Provisioned = { userId: string; keyId: string; keySecret: string };

interface Accounts {
  transaction<T>(fn: (tx: Accounts) => Promise<T>): Promise<T>;
  findSignup(idempotencyKey: string): Promise<Provisioned | null>;
  createUser(input: { tenantId: string; email: string; idempotencyKey: string }): Promise<{ userId: string }>;
  enqueueKeyGrant(input: { userId: string; scopes: string[]; spendCents: number }): Promise<{ keyId: string }>;
  saveKeyHash(keyId: string, hash: string): Promise<void>;
}

interface MailQueue {
  publish(message: { type: "welcome"; userId: string; email: string; keyId: string }): Promise<void>;
}

async function signup(input: Signup, db: Accounts, mail: MailQueue): Promise<Provisioned> {
  const existing = await db.findSignup(input.idempotencyKey);
  if (existing) return existing;

  const result = await db.transaction(async (tx) => {
    const user = await tx.createUser(input);
    // Read-only scope and a hard spend budget for the first onboarding session.
    const key = await tx.enqueueKeyGrant({
      userId: user.userId,
      scopes: ["profile:read", "projects:read"],
      spendCents: 2500
    });
    return { userId: user.userId, keyId: key.keyId };
  });

  const keySecret = await issueSecretOnce(result.keyId);
  await db.saveKeyHash(result.keyId, await sha256(keySecret));
  await mail.publish({ type: "welcome", userId: result.userId, email: input.email, keyId: result.keyId });
  return { ...result, keySecret };
}

declare function issueSecretOnce(keyId: string): Promise<string>;
declare function sha256(value: string): Promise<string>;
Enter fullscreen mode Exit fullscreen mode

The secret is returned once, over TLS, and never placed in the email body or application logs. The database keeps a digest and metadata such as scopes, creator, and expiry. If the process dies after issueSecretOnce, a durable grant record lets a reconciler complete the hash and email steps without silently creating a second key. I initially assumed the queue was the hard part. It was the boundary between commit and side effect.

Keep secrets out of screenshots, too.

In my drill, I submit the same idempotency token from two browser tabs while the worker is paused. Both requests see the same tenant row; only one gets a key-grant row because the database constraint wins the race. I then resume the worker, terminate it immediately after the secret is generated, and run reconciliation. The reconciler finds a grant with no digest, hashes the one-time value held by the delivery adapter, and publishes exactly one welcome event. Finally I replay the request after rotation: it returns the existing account record, never the revoked secret. This sequence is longer than a happy-path unit test, but it is the path that protects a spend ceiling when a deploy, retry, or impatient user overlaps the workflow.

That boundary deserves a test for each transition: duplicate idempotency keys return the same result, a rejected spend budget creates no active key, and an email timeout leaves a retryable welcome_pending state. A short test is useful here.

const first = await signup(request, fakeDb, fakeMail);
const retry = await signup(request, fakeDb, fakeMail);
assert.equal(retry.keyId, first.keyId);
assert.equal(fakeMail.messages.length, 1);
Enter fullscreen mode Exit fullscreen mode

How do scoped credentials and spend ceilings interact with refused traffic?

A scope answers “what may this key call?” A spend ceiling answers “how much may it consume?” They are different controls and need separate fields in policy. Enforce both at the request gateway, before an expensive model or storage operation starts. Return 403 for a scope violation and 429 for a budget or rate refusal; the client can then distinguish a bad capability from a temporary retry decision.

Keep the budget ledger append-only enough to explain a refusal. Record tenant, key ID, estimated units, settled units, and policy version. Estimates protect the ceiling before work begins; settlement corrects the estimate afterward. Your mileage may vary on token accounting because providers expose different usage units, so expose a normalized internal unit and retain the raw provider report for audits.

The catch is that a hard ceiling can reject legitimate onboarding traffic during a burst. That is the intended trade-off for a solo team that cannot absorb an unbounded bill. Use a small grace reserve only if the business accepts that risk; otherwise, stick with a queue and a clear 429 response. This design is not suitable when requests must never be refused, such as a safety-critical control plane; use an approved capacity reservation and human escalation there.

Which implementation choices survive retries, rotation, and audits?

Use a unique constraint on (tenant_id, idempotency_key) and another on an active key fingerprint. Make email delivery an outbox event, not a transaction callback. The outbox row contains no secret, has a bounded retry count, and records the provider message ID. Rotation creates a replacement key, marks the old key with a short overlap window, and revokes it after clients acknowledge the new one.

Observability should answer four questions in one trace: who requested signup, which policy version ran, why traffic was accepted or refused, and whether the welcome message was delivered. Hash email addresses in metrics, redact authorization headers, and sample bodies out of traces. OWASP's Secrets Management Cheat Sheet recommends minimizing secret exposure and planning rotation and revocation; those are practical acceptance criteria, not paperwork.

Before shipping, I run the drill with a disposable tenant: submit the same request three times, kill the worker between key issuance and hashing, exceed the 2,500-cent budget, rotate the key, and inspect logs for leaked material. The pass condition is boring: one user, one active scoped key, one welcome event, and a recorded refusal when the ceiling is crossed.

Further reading

Top comments (0)