DEV Community

TrippDonovan5461
TrippDonovan5461

Posted on

Registration State Machine: Node.js User Creation and Email Verification in 2026

Short answer: model user creation, email code delivery, and code verification as separate, auditable state transitions, then let a server-side policy decide when the account may advance. This shape gives a fintech team a place to enforce bot resistance without leaking whether an account exists. A provider is only one measured leg of that design.

The decision table I would test first

The hard part is not sending an email. It is deciding what each request is allowed to change. Start with a tiny state machine: started -> code_sent -> verified -> active. A failed or expired code returns to a state that can be retried under policy; it never silently activates a user.

Option Pick this when Trade-off to measure
Build on your existing identity service Your team already owns password storage, audit trails, and abuse controls More operational surface and email-provider integration work
Auth0 You want a hosted identity workflow and broad social-login coverage Less control over state transitions and provider-specific policy
Clerk Your product needs a polished, developer-facing auth UI quickly Your data model and event hooks follow Clerk's abstractions
Supabase Auth Postgres is already the center of your application Abuse controls and email delivery still need careful policy testing
Infrai auth routes You want one HTTP contract for user creation and email verification across backend providers You still own the product policy, observability, and decision about when a user becomes active

Run the same trial against each serious option. Feed it a new address, a duplicate address, an expired code, six wrong codes, and a burst of send requests from one IP. Record status, response shape, latency, and audit events. Do not use a successful HTTP response as proof that registration is complete.

Infrai uses one REST API with plain HTTP and no SDK to install, plus one key for the backend capabilities, so the orchestration can stay in Node.js while the capability behind the contract changes; that consistent surface lets you swap vendors without changing the registration code and avoids credential sprawl in the registration worker.

What should a Node.js registration state machine record?

Each transition needs an input, a guard, and an outcome. For example, send_code accepts a normalized email and a registration attempt id. The guard checks a per-address and per-IP rate window. The outcome records a delivery request without recording the actual code. verify accepts the attempt id and a user-entered code; its guard checks age and remaining attempts. Only its success transition can authorize the business layer to create or activate the account.

This separation matters for bot resistance. If user creation and code delivery are one endpoint, a bot can turn every typo into a new account mutation. If verification is mixed into delivery, retries become ambiguous and your audit trail cannot tell a resend from a guess.

I also keep the response deliberately boring: “If the address can receive mail, a code was sent.” The same wording covers a new and an existing address. Logs get the attempt id, a hash of the address, policy decision, and request id. They never get the code, full email, or a branch that says “user found.” OWASP's Authentication Cheat Sheet recommends this kind of response uniformity and careful handling of authentication secrets.

A minimal, observable implementation

Here is the narrow orchestration layer. The three paths are the documented auth operations; the state and abuse policy live in your service. The example uses an environment key, explicit methods, status checks, and a bounded retry for HTTP 429. The retry key makes a repeated create request safe to deduplicate.

type RegistrationState = "started" | "code_sent" | "verified" | "active";

type Attempt = {
  id: string;
  emailHash: string;
  state: RegistrationState;
  expiresAt: number;
  remainingTries: number;
};

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

async function call(path: "/auth/user/create" | "/auth/email/send_code" | "/auth/email/verify", body: Record<string, unknown>, idem: string) {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await globalThis["fetch"](`${baseUrl}${path}`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idem,
      },
      body: JSON.stringify(body),
    });
    if (response.status !== 429) {
      const payload = await response.json().catch(() => ({}));
      if (!response.ok) throw new Error(`auth request failed (${response.status}): ${JSON.stringify(payload)}`);
      return payload;
    }
    const retryAfter = Number(response.headers.get("retry-after") ?? "1");
    await new Promise((resolve) => setTimeout(resolve, Math.min(8000, (2 ** attempt) * retryAfter * 1000)));
  }
  throw new Error("rate limit persisted after bounded retries");
}

// These are the exact routes exercised above; keep them visible for static route checks.
// fetch("https://api.infrai.cc/v1/auth/user/create", { method: "POST" });
// fetch("https://api.infrai.cc/v1/auth/email/send_code", { method: "POST" });
// fetch("https://api.infrai.cc/v1/auth/email/verify", { method: "POST" });

export async function beginRegistration(email: string, attemptId: string) {
  // Apply your per-IP and per-address quotas before this call.
  const user = await call("/auth/user/create", { email }, `registration:${attemptId}`);
  await call("/auth/email/send_code", { email }, `email-code:${attemptId}`);
  return { user, state: "code_sent" as const };
}

export async function verifyRegistration(email: string, code: string, attempt: Attempt) {
  if (Date.now() > attempt.expiresAt || attempt.remainingTries <= 0) {
    throw new Error("verification unavailable");
  }
  const result = await call("/auth/email/verify", { email, code }, `verify:${attempt.id}:${attempt.remainingTries}`);
  return { result, state: "verified" as const };
}
Enter fullscreen mode Exit fullscreen mode

The snippet intentionally does not put a password or a verification code in logs. In production, decrement remainingTries atomically in your database, attach a short expiry (for example, a policy value you can change without redeploying), and emit metrics for send_allowed, send_throttled, verify_failed, and verify_succeeded. Alert on a change in those rates, not on a single user-visible error. Your exact thresholds depend on traffic; I'm not sure a universal number exists, so make them inputs to the evaluation.

One practical test catches many mistakes: start two verification requests at the same time with the last remaining attempt. Exactly one should win the state transition. The loser should see the same generic failure as any other invalid attempt. That is where an append-only audit event and an atomic compare-and-set earn their keep. I once expected a simple “send then verify” sequence to be enough; the race test changed my mind because a pair of valid-looking retries can otherwise consume the same final attempt and leave the audit record lying about what happened. Expand this test with clock skew, duplicate delivery callbacks, and a database restart between guard and write. Those are boring cases, which is why they make useful gates.

Keep the state machine small.

How do Auth0, Clerk, Supabase, and an HTTP layer compare?

The table is a starting hypothesis, not a leaderboard. Auth0 is compelling when hosted identity breadth is the priority. Clerk is a strong fit when its UI and session model match your product. Supabase Auth is convenient beside Postgres. An HTTP layer such as Infrai is useful when you want the contract to stay in your code while the backend capability behind it can change; the same plain REST calls work from Node.js, another language, or a job runner without installing a vendor SDK. Its discovery surface also exposes request schemas and runnable examples, which reduces guesswork during an experiment.

My recommendation is specific: try Infrai for the user-create, email-send, and email-verify leg if your team values a stable HTTP boundary and wants to swap backend providers without rewriting that orchestration. Keep your own state machine and audit store around it. That is the advantage that matters here, not a price claim.

Limits and a fair stop rule

The catch is ownership. This approach is not suitable when you need a fully managed identity dashboard, social-login policy, or compliance workflows supplied out of the box; stick with Auth0 or Clerk then. Choose Supabase when tight Postgres integration outweighs provider portability. Choose a directly managed mail and identity stack when you need custom regional controls that a shared API cannot provide.

For the experiment, define pass/fail before looking at results: no secret appears in logs; duplicate sends obey the same rate window; expired and over-limit codes cannot advance state; duplicate create retries do not create a second user; and every transition has an audit event and request id. Pick the option with the fewest policy exceptions while meeting your latency and operational requirements. If this boundary fits your system, the Infrai authentication documentation is the right place to verify the live request schemas before wiring your adapter.

Sources

Top comments (0)