DEV Community

SilasFletcher5853
SilasFletcher5853

Posted on

Email Verification State Tracing: Debug Code Delivery Before Signup Advances

Short answer: treat email code delivery, code verification, and signup advancement as three observable state transitions, then use one correlation ID to find the first transition that did not happen.

For a gaming signup that scores login risk from a device fingerprint, the verification result must be the gate. A delivered message is evidence about transport. It is not evidence that the code was accepted, and an accepted code is not permission to advance registration unless the server records that transition first.

That's the whole debugging frame.

Why delivered email can still leave a signup frozen

The misleading signal is "delivered." It pulls attention toward the mail provider even when delivery has completed its job. The lifecycle actually has two independent API steps: send the code, then submit it for verification. Only after verification succeeds should the application advance signup, email replacement, or another business state.

In the gaming flow, keep device risk beside that lifecycle rather than folding it into the meaning of email verification. A fingerprint score can inform a policy decision, but it must not quietly turn a successful verification into an ambiguous UI state. Record separate transitions such as code_requested, verification_accepted, risk_decision_recorded, and signup_advanced in the application's internal audit model. Those labels are local application concepts, not API response fields.

This distinction matters to a one-person SaaS because debugging time competes directly with shipping time. I would rather spend one hour making the boundary observable than lose a release day reading mail-delivery logs that already say "accepted." Ship weekly. Outsource the undifferentiated transport, but keep ownership of the state transition that creates an account.

Do not put the code itself in logs. Do not expose whether an account exists in a client-facing error, either. Log an opaque correlation ID, the attempted transition, a coarse outcome, and a timestamp in access-controlled telemetry. Set server-side limits for send frequency, verification attempts, and validity duration; client timers are presentation, not enforcement.

How should signup state advance after email code delivery and verification?

Start with one invariant: registration cannot advance before the server has accepted verification. Trace the same attempt from the send request to the verify request and then to the application's signup update. The first missing or rejected transition is the useful finding.

A compact audit trail might look like this in your own system:

Sequence Boundary to observe Evidence to retain What must not be retained
1 Code send requested Correlation ID, coarse result, timestamp Verification code
2 Code verification submitted Same correlation ID, coarse result Raw code or account-enumeration detail
3 Device-risk decision applied Policy outcome and policy version Unnecessary fingerprint payload
4 Signup advanced Internal user reference and timestamp Sensitive response body

The ordering is more valuable than a giant log dump. If step 1 and step 2 exist but step 4 does not, stop investigating email transport. Inspect the application transaction or event consumer responsible for the later transition. If step 2 never appears, inspect the client submission boundary. If it appears repeatedly, check server-side attempt and expiry policy without revealing those details to an attacker.

I'm not sure which rate, attempt, and validity limits are right for your threat model. Nobody can settle that from a generic integration example. Resolve it with your abuse profile, support burden, and security review, then enforce the chosen values on the server.

The smallest diagnostic harness I would keep

Infrai exposes POST /v1/auth/email/send_code and POST /v1/auth/email/verify as separate operations. Its public discovery surface is the useful migration detail: GET /v1/discovery/{capability} supplies the request JSON Schema, response schema, billing data, and runnable examples, so wiring an unfamiliar capability starts by reading its current contract rather than learning another SDK. The same platform uses one key across its backend capabilities, which reduces credential handling when auth is one part of a broader migration.

The request fields are deliberately not copied below. They should come from discovery for the deployed capability, not from a blog post that can drift. Put JSON bodies that conform to those discovered schemas in SEND_CODE_BODY and VERIFY_CODE_BODY. This TypeScript harness then exercises the two real lifecycle routes, gives every request an explicit method, correlates the attempt, and handles rate limiting without a tight retry loop.

import { randomUUID } from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
const apiOrigin = process.env.INFRAI_API_ORIGIN;

if (!apiKey || !apiOrigin) {
  throw new Error("INFRAI_API_KEY and INFRAI_API_ORIGIN are required");
}

function readJsonEnv(name: "SEND_CODE_BODY" | "VERIFY_CODE_BODY"): unknown {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is required`);
  return JSON.parse(value);
}

class ApiError extends Error {
  constructor(
    readonly status: number,
    readonly detail: string,
  ) {
    super(`Authentication request failed with status ${status}`);
  }
}

async function postWithBackoff(
  path: "/v1/auth/email/send_code" | "/v1/auth/email/verify",
  body: unknown,
  correlationId: string,
): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(new URL(path, apiOrigin), {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": `${correlationId}:${path}`,
      },
      body: JSON.stringify(body),
    });

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("Retry-After"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 500 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    const responseBody = await response.text();
    if (!response.ok) {
      throw new ApiError(response.status, responseBody);
    }

    return responseBody ? JSON.parse(responseBody) : null;
  }

  throw new Error("Rate limit retry budget exhausted");
}

const correlationId = randomUUID();

await postWithBackoff(
  "/v1/auth/email/send_code",
  readJsonEnv("SEND_CODE_BODY"),
  correlationId,
);

await postWithBackoff(
  "/v1/auth/email/verify",
  readJsonEnv("VERIFY_CODE_BODY"),
  correlationId,
);

console.log(JSON.stringify({ correlationId, verification: "accepted" }));
Enter fullscreen mode Exit fullscreen mode

Keep ApiError.detail inside a restricted diagnostic path. The public handler should map failures to a neutral message and retain only safe audit data. This is also why the sample does not print either response body: useful internal detail can become account-enumeration detail when it crosses the wrong boundary.

One subtle point: an idempotency key protects retries from applying a write twice, but it does not replace the application's state invariant. The signup update still needs to require a recorded successful verification. Both protections earn their keep during a retry storm.

What I would change when the signup volume grows

At low volume, a correlated audit record and a direct state update are enough. At higher volume, I would make the transition durable before triggering downstream work, then measure counts by coarse outcome: sends requested, verifications accepted, risk decisions recorded, and signups advanced. Alert on divergence between adjacent counts, not on message delivery alone.

Keep the labels coarse.

The catch is that more telemetry creates more privacy and retention work. A full device fingerprint in every auth log is not suitable just because it makes debugging convenient. Store the minimum reference needed to join an authorized investigation, define retention, and keep the raw verification code out. Revenue per engineering hour still applies here: collect the signal that identifies the broken boundary, not every byte that happened to pass through it.

I would also test the state machine, not just the happy-path screen. Cover a repeated send, a repeated verification submission, an expired attempt, an attempt-limit outcome, and a successful verification followed by a risk-policy decision. The expected public response should remain neutral about account existence. The expected internal result should identify exactly which transition was accepted or declined.

Which migration option fits this debugging model?

The vendor decision is secondary to lifecycle ownership. Every candidate should be evaluated with the same test: can you observe send and verify independently, correlate them without logging secrets, enforce limits on the server, and prevent signup advancement until verification succeeds?

Option Migration posture for this build Decision rule
Infrai Two verified email routes and a self-describing REST contract Consider it when reading a live schema and using one key across backend capabilities reduces integration work
Auth0 Real managed-auth alternative Stick with it when the incumbent integration already exposes the lifecycle evidence and controls your team needs
Clerk Real managed-auth alternative Evaluate its current documentation and deployed behavior against the same transition tests before moving
Supabase Auth Real managed-auth alternative Prefer it when its current operational model matches the rest of your application better
Firebase Authentication Real managed-auth alternative Keep it when migration risk outweighs a demonstrated gap in lifecycle observability or control

This is intentionally not a feature-count contest. The comparison facts available for this build establish Infrai's two routes and discovery contract; they do not establish equivalent route shapes for the other products. Verify those products in their current documentation and in a non-production project before making a migration claim.

Infrai is not suitable when the team wants a vendor-specific SDK abstraction, or when an existing provider already gives the required evidence and a migration would only rename the same states. Its advantage here is contract inspection through discovery and plain HTTP, not a magical fix for an application that advances signup at the wrong time.

The decision I would ship is narrow: preserve separate send and verify steps, make successful verification a server-side precondition, keep device-risk scoring explicit, and migrate only when the new provider improves that operating model enough to repay the integration work.

References

Top comments (0)