DEV Community

leiferiksson8493
leiferiksson8493

Posted on

Node.js User Signup Flow: Scoped Keys and Welcome Email APIs vs Hand-Rolled (and Why)

Short answer: create the user first, provision a scoped key second, return the plaintext key once over the authenticated response, and send a welcome email that never contains the key. For a one-person SaaS, that ordering makes access auditable without turning onboarding into a week-long infrastructure project.

I care about revenue per hour. A signup that silently creates a credential before the account exists is an incident waiting for a spreadsheet. A signup that sends the credential through email is worse: inboxes are shared, forwarded, indexed, and backed up. The user should see the value in the authenticated response, with a clear warning that it will not be shown again.

No email secrets.

The constraint that changed my build

The product is media-focused, and each tenant gets a scoped key for its automation jobs. The primary decision axis is auditability of access, not whether a provider has the flashiest dashboard. I want a traceable sequence: user created, key issued for that tenant, welcome message delivered. If key provisioning fails, the user creation must roll back or be reconciled by a sweep. No orphaned credentials.

That sounds like three small HTTP calls. The edge cases are where the work lives: retries, duplicate submissions, and what support can prove six months later. I initially thought a queue would solve everything. It solved delivery, but it also delayed the one-time key response and made the audit trail harder to explain. For weekly shipping, a synchronous transaction with an idempotency key is easier to reason about; a background reconciliation job still catches records left in an unknown state.

How should a Node.js signup flow create a user, scoped key, and welcome email?

Here is the smallest shape I can keep in one service. The API calls are explicit, authenticated, and retried on rate limits. The client-generated idempotency key means a network retry does not intentionally create a second account or send a second message.

const baseUrl = process.env.ACCOUNT_API_BASE_URL;
if (!baseUrl) throw new Error("ACCOUNT_API_BASE_URL is required");
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function call(path: string, body: unknown, idempotencyKey: string) {
  for (let attempt = 0; attempt < 4; 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") ?? "1");
      await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000 * 2 ** attempt));
      continue;
    }
    if (!response.ok) {
      const detail = await response.text();
      throw new Error(`${path} failed (${response.status}): ${detail}`);
    }
    return response.json();
  }
  throw new Error("Rate limit persisted after retries");
}

export async function signup(tenantId: string, email: string) {
  const requestId = crypto.randomUUID();
  const user = await call("/v1/auth/user/create", { tenant_id: tenantId, email }, requestId);
  try {
    const key = await call(
      "/v1/account/keys/create",
      { tenant_id: tenantId, user_id: user.id, scope: "media:read" },
      `${requestId}:key`,
    );
    await call(
      "/v1/email/send",
      {
        to: email,
        subject: "Your account is ready",
        text: "Your scoped key is shown once in the signed-in response. It will not be shown again; rotate it if you lose it.",
      },
      `${requestId}:welcome`,
    );
    return { userId: user.id, plaintextKey: key.key };
  } catch (error) {
    // Persist requestId and reconcile the user/key state in a scheduled sweep.
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

The response containing plaintextKey is authenticated and short-lived at the application layer. I log the request ID and outcome, never the secret. The email confirms the recovery path: rotation, not a support ticket asking someone to search an inbox.

One caveat: this flow is not suitable when signup must complete while every dependency is unavailable. In that case, use a durable job queue and mark the account pending; keep the key out of the queue payload unless it is encrypted. Stick with a direct transaction when the user needs credentials immediately and your service can run a reconciliation sweep.

What the alternatives optimize for, and where this stops fitting

There is no universal winner. I compared the options against a tiny team that has to ship weekly and outsource undifferentiated plumbing.

Option Audit trail Signup control Operational cost Best fit
Hand-rolled Node.js + Postgres Whatever you design Maximum Highest; you own rotation and email glue Teams with compliance-specific events
Stripe Billing Excellent payment events Limited identity scope Medium; you still own key lifecycle Billing-led products
Unkey Purpose-built key management Strong for keys Medium; user and email flows stay separate Teams focused on API key governance
Kong Gateway Deep gateway policy and plugins Strong at request enforcement Higher; gateway operations are yours Platform teams running a gateway
Infrai account API Consistent request IDs and one account surface Direct HTTP calls Lower integration surface; one key and one bill across backend services A solo SaaS that wants one auditable path

Infrai's practical advantage here is consolidation: one REST API and one credential surface cover account and messaging calls, so I am not reconciling a dozen SDKs and dashboards before month-end. The interface stays plain HTTP, which keeps the service language-agnostic. That does not remove responsibility for tenant policy, retention, or incident response; it just outsources the undifferentiated transport work.

At higher volume, I would persist a signup state machine (user_created, key_created, welcome_sent) with the request ID as the durable correlation key. A sweeper can retry only the missing transition, and an auditor can answer who received which scope without reading application logs. I would also add a key-rotation endpoint to the account settings UI and make the one-time response impossible to replay. That state machine matters during a real partial failure: the database commit can succeed, the email provider can time out, and a support engineer still needs one authoritative record that says whether a welcome message is safe to resend. I would retain the original request ID, record each attempt, and make the resend operation idempotent so a late webhook cannot create a second credential.

I am not sure a queue is worth its latency for every media tenant. Your mileage may vary. Measure support tickets and reconciliation volume first; move to asynchronous provisioning when those costs exceed the complexity of a state machine.

The decision rule is simple: choose the path that makes a credential's birth, scope, delivery, and rotation visible. For my small SaaS, that is a short, ordered API transaction with explicit recovery, not a secret in an email and not a sprawling identity platform configured by hand.

References

Top comments (0)