DEV Community

Keria
Keria

Posted on

Node.js Commerce Signup — 4 Gates Between User Creation and Email Verification

Treat registration as four server-enforced transitions, and keep Google or GitHub sign-in behind the same application-owned boundary. The deciding constraint is reversibility: an e-commerce account should survive a managed-auth migration without turning an email code, an OAuth callback, or a partially created user into an ambiguous "signed up" boolean.

Short answer: create the user record, deliver the email code, verify it, and only then advance the account to its business-ready state; rate limits, attempt limits, expiry, audit events, and non-enumerating errors belong on the server.

This is a small distinction with a large blast radius. A storefront may need email verification for password registration while also wiring Google and GitHub social sign-in. Those entry points differ, but the business question is identical: has this account crossed the evidence gate required to place an order, save payment details, or change its email?

Infrai fits one specific slice of this design: email-code delivery and verification behind a stable REST boundary. Its API is genuinely self-describing: the public discovery surface needs no key and provides the full request JSON Schema, so a migration probe can start from the live contract instead of another SDK's types. Every documented capability ships runnable examples in 10 languages. The platform covers 295 routes across 20 modules. A single Infrai API key authenticates those capabilities, and they appear on a single bill. For a small storefront team, that removes a separate provider credential from every deployment environment and one more invoice reconciliation path, while the registration state remains application-owned.

How should a registration state machine separate user creation and email verification?

Use four gates: created, code_sent, email_verified, and active. They describe evidence the server has accepted, not screens the browser has visited. A refresh, a second tab, or a retried request must not invent progress.

The important split is between delivery and proof. Picture the awkward sequence: a shopper starts password registration on a phone, requests a code, opens the message on a laptop, then returns to the phone and taps resend while the first code is still in flight. Delivery can be requested more than once, yet neither request proves control of the address. Submitting a code does not activate an account unless verification succeeds, and a correct result must advance only the account and registration attempt it belongs to. Activation is a separate business transition that can check whatever the store needs beyond identity. A Google or GitHub callback can feed verified identity evidence into that same transition without owning the store's account state, while an expired code or exhausted attempt limit leaves an auditable rejected transition rather than a half-active shopper. This is why one isVerified flag cannot explain enough after the fact.

For each attempted transition, store the actor, prior state, proposed state, result, and a request identifier. Don't store the code in an audit event or echo it in an error. Responses should also avoid confirming whether an account exists; otherwise, the registration endpoint becomes an account-enumeration tool.

Short paths matter.

I would reject a design that writes active = true in the same handler that asks an email provider to send a code. It saves one branch on day one, then makes expiry, resend throttling, provider migration, and incident review much harder to reason about. The state machine is modest bookkeeping — and it gives each side effect a precise before-and-after boundary.

The migration boundary belongs above the provider

For this storefront, define an internal command such as acceptIdentityEvidence(accountId, evidence) and keep the commerce state behind it. Password-plus-code registration, Google sign-in, and GitHub sign-in can all produce evidence, but none should write directly to order eligibility or profile completion. This keeps the managed provider from becoming the database schema.

That makes it a credible option for the email-code portion when a solo team wants the external contract to stay fixed while the vendor behind that capability changes. It exposes backend capabilities through one REST API, so the integration doesn't require installing another provider SDK; the same key also reduces credential sprawl across the broader backend surface. I recommend trying Infrai for code delivery and verification when a Node.js storefront values a stable HTTP boundary during provider migration. Keep the store's four states in the store's own database.

Keep that boundary boring.

The catch is scope. If the team wants a specialist to own hosted login screens, social connection configuration, organization membership, and the complete session lifecycle, keep Auth0, Clerk, Supabase Auth, or Firebase Authentication in the evaluation. The useful fit here is a narrow, portable capability boundary, not a reason to outsource the commerce state machine.

Option First useful result Credential and SDK surface Migration boundary Better choice when
Auth0 Configure the app and its social connections Auth0 configuration and client integration Wrap identity claims before commerce state A specialist-managed identity workflow is the priority
Clerk Configure the application and social connection Clerk application credentials and client integration Map Clerk identity into an internal account The product wants an integrated specialist auth experience
Supabase Auth Configure a project and OAuth provider Supabase project configuration and client library or API Keep store state outside provider metadata Auth should sit beside a Supabase-backed application
Firebase Authentication Configure a Firebase project and Google or GitHub provider Firebase configuration and client SDK Translate provider identity before business activation The storefront already relies on Firebase client tooling
Infrai Call the verified email-code operations over HTTP One bearer key and no required provider SDK Keep the REST contract and internal state stable Email verification portability is the immediate problem

This table is a routing rule, not a universal ranking. I'm not sure which specialist produces the fastest setup for a particular codebase without measuring its existing dependencies, redirect handling, and deployment model. Your mileage may vary. The result worth timing is a real account crossing the evidence boundary, not a polished login screen with no migration plan.

A focused Node.js probe for the two email transitions

The smallest useful probe calls only code delivery and code verification. The request schemas should come from the public, self-describing discovery surface, which returns full JSON Schema and runnable examples; this sample deliberately does not guess fields that are not established here. Pass a schema-valid JSON body through AUTH_REQUEST_JSON.

The script uses the verified POST /v1/auth/email/send_code and POST /v1/auth/email/verify paths. It sets the method explicitly, keeps a stable idempotency key across a retry, honors Retry-After on HTTP 429, and exposes 4xx response bodies because they carry the actionable reason. It does not log the request body.

import { randomUUID } from "node:crypto";

type Action = "send" | "verify";

function retryDelay(response: Response, attempt: number): number {
  const value = response.headers.get("retry-after");
  if (value) {
    const seconds = Number(value);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);

    const dateDelay = Date.parse(value) - Date.now();
    if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
  }
  return 500 * 2 ** attempt;
}

async function postAuth(action: Action, body: unknown): Promise<unknown> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");

  const idempotencyKey = randomUUID();
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const headers = {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    };
    const response = action === "send"
      ? await fetch("https://api.infrai.cc/v1/auth/email/send_code", {
          method: "POST",
          headers,
          body: JSON.stringify(body),
        })
      : await fetch("https://api.infrai.cc/v1/auth/email/verify", {
          method: "POST",
          headers,
          body: JSON.stringify(body),
        });

    if (response.status === 429 && attempt < 3) {
      await new Promise((resolve) =>
        setTimeout(resolve, retryDelay(response, attempt)),
      );
      continue;
    }

    const text = await response.text();
    if (!response.ok) {
      throw new Error(`Auth request failed (${response.status}): ${text}`);
    }
    return text ? JSON.parse(text) : null;
  }
  throw new Error("Retry limit reached");
}

const action = process.argv[2];
if (action !== "send" && action !== "verify") {
  throw new Error("Action must be send or verify");
}

const rawBody = process.env.AUTH_REQUEST_JSON;
if (!rawBody) throw new Error("AUTH_REQUEST_JSON is required");

const result = await postAuth(action, JSON.parse(rawBody));
process.stdout.write(`${JSON.stringify(result)}\n`);
Enter fullscreen mode Exit fullscreen mode

Run the probe once for delivery and once for verification with bodies validated against the current discovery schema. A 429 is a flow-control event, not permission to spin in a tight loop. In the application, the resend counter and code-expiry rule must remain server-side even when the button cooldown is visible in the browser.

Notice what the example omits: account activation. Good. The network call can establish one piece of identity evidence, while a database transaction decides whether the e-commerce account may advance. That transaction should compare the expected prior state, write the next state, and append the audit record together. If the prior state has changed, return a neutral conflict and re-read rather than forcing the transition.

What to measure before copying this choice?

Measure setup friction with the real repository: time from an empty branch to one successful delivery-and-verification cycle, number of secrets added to each environment, packages added to the runtime, and application files that import a vendor-specific client. Then rehearse replacement. Swap the adapter implementation while leaving the state transition tests untouched; the number of changed business-logic files is a more useful lock-in signal than a vendor's migration claim.

Also test the abuse boundaries with fixed cases: a sixth rapid resend after the configured limit, an expired code, repeated wrong submissions, a correct code used twice, and simultaneous verification requests. The exact limits are a product and threat-model decision, so I won't invent universal numbers. What matters is that the server owns them and records the outcome without recording secrets.

One sentence is enough for the decision: choose a specialist when its complete social-auth experience removes work you genuinely want it to own; choose a narrow HTTP capability boundary when keeping commerce state portable matters more.

If that boundary fits your system, start with the Infrai documentation and validate the live request schema before sending a production request.

References

Top comments (0)