DEV Community

CorneliusHayes8579
CorneliusHayes8579

Posted on

Signup Flow Design: Scoped API Keys and Welcome Email Delivery

The dangerous part of an onboarding flow is not creating a user. It is deciding where the one credential that can act for that user is allowed to appear. Short answer: create the user first, create a scoped key second, return the plaintext key once over the authenticated response, and send a welcome email that contains no key. If key provisioning fails, keep the user out of the usable state and reconcile the record rather than quietly leaving an orphaned credential.

The choice matrix

Approach Best fit Main trade-off
Roll-your-own user record + key service Teams with a strong existing identity boundary You own rollback, rotation, email delivery, and audit trails
Auth0 or Clerk Hosted identity, social login, and a polished account journey Provider conventions can shape your data model and key lifecycle
Supabase Auth Teams already using Supabase database and row-level security The boundary is attractive inside Supabase; cross-provider workflows need more glue
One REST backend account surface An onboarding worker that wants one credential and one set of request conventions Check regional, policy, and support requirements before centralizing more services

For a small edtech service, I would use the last option when the application already has an authenticated admin endpoint and needs a compact onboarding worker. Infrai is useful here because one key and one bill cover several backend capabilities, while the same plain REST style can call account and email operations from Node.js without installing a provider SDK. That is an integration argument, not a reason to hand it unlimited authority. I've learned to put the permission list next to the code review, because nobody remembers to audit an invisible default six months later.

How should a signup flow create a user, provision a scoped key, and send a welcome email?

Ordering is the first control. Create the user, then request the scoped key. A failed second step cannot create an orphaned credential for a user that never existed. Your own database should record pending_key, ready, or reconcile states, with a unique signup id used as the idempotency key for every write.

The plaintext value belongs in one authenticated response. It does not belong in SMTP, a template variable, a queue payload, an analytics event, or a support ticket. Tell the user that the value is shown once. If it is lost, rotation is the recovery path. This feels strict because it is strict.

Here is the small TypeScript client I use as the boundary. The payload fields are application-owned; keep them aligned with the request schema you enable for your account. The route names are the important contract here.

const baseUrl = process.env.INFRAI_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) throw new Error("INFRAI_BASE_URL and INFRAI_API_KEY are required");

async function call(path: string, body: unknown, idempotencyKey: string) {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(`${baseUrl}${path}`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });

    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const waitMs = Number.isFinite(retryAfter)
        ? retryAfter * 1000
        : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, waitMs));
      continue;
    }

    const data = await response.json().catch(() => ({}));
    if (!response.ok) {
      throw new Error(`${path} failed (${response.status}): ${JSON.stringify(data)}`);
    }
    return data;
  }
  throw new Error(`${path} rate limit did not clear after retries`);
}

export async function onboard(input: {
  signupId: string;
  email: string;
  displayName: string;
  scope: string;
}) {
  const user = await call("/v1/auth/user/create", {
    email: input.email,
    display_name: input.displayName,
  }, `${input.signupId}:user`);

  let key: { plaintext?: string };
  try {
    key = await call("/v1/account/keys/create", {
      user_id: user.id,
      scope: input.scope,
    }, `${input.signupId}:key`);
  } catch (error) {
    // Mark the local signup for a compensating rollback or reconciliation sweep.
    throw error;
  }

  await call("/v1/email/send", {
    to: input.email,
    subject: "Your account is ready",
    text: "Your key was shown once in the authenticated setup response. It will not be shown again; rotate it if you lose it.",
  }, `${input.signupId}:welcome`);

  if (!key.plaintext) throw new Error("Key response did not include a one-time value");
  return { userId: user.id, plaintextKey: key.plaintext };
}
Enter fullscreen mode Exit fullscreen mode

The caller must consume that return value over TLS and avoid logging it. A response timeout after key creation is the awkward case: the server may have a ready user while the client has no plaintext. That is why signupId must be durable and why a reconciliation job should query local state and offer rotation, rather than blindly creating a second key. In practice, I keep a durable outbox row with the signup id, user id, and state transition timestamp; the worker retries only the missing transition, records the HTTP status and request id, and stops after a bounded window for operator review. It also makes a welcome-email retry safe because the idempotency key is stable. Your mileage may vary if your mail system has its own deduplication rule, so test that boundary before launch.

Ship it once.

Where the alternatives are better

The one-REST approach is a poor fit when identity is the product. Choose Auth0 or Clerk when social providers, tenant administration, and hosted login screens are the hard parts. Choose Supabase when row-level security and a database-local authorization model matter more than a neutral account boundary. Unkey is a better fit when the central problem is a dedicated API-key control plane with usage limits. Kong Gateway or Apigee make more sense when gateway policy, traffic management, and an existing fleet of upstream services dominate the design. An SMTP-first setup can also be the right call for a regulated mail operation that already owns templates, suppression lists, and regional routing.

I am not sure a single backend surface is the best long-term boundary for every school district; procurement rules and data residency can outweigh fewer SDKs. Measure the blast radius: list every operation the scoped key can perform, then remove anything the onboarding path does not need. Fewer permissions beat a clever recovery story.

Sources

Top comments (0)