DEV Community

WilfredKnight8447
WilfredKnight8447

Posted on

How to Troubleshoot Email Verification When Code Delivery Succeeds (Signup Audit)

Short answer: treat email verification as a state machine, not a send-email callback. When a property-management signup stalls after the code is delivered, log one correlation id across captcha, mail, verification, and account creation. Then compare browser state with server state. A captcha can stop bots; it cannot repair a lost transition.

Diagnostic path Best fit Trade-off
Server-owned state machine Teams migrating off a managed provider More code and tests to own
Managed identity workflow Small teams with limited auth operations Less control over callbacks and exports
Email-link fallback Desktop-heavy leasing offices Awkward on shared tablets

My default for a property portal is the first row. Keep the provider boundary narrow: deliver a message, while the application owns the pending signup record and captcha decision. That makes migration a contract exercise instead of a rewrite.

What should you inspect when email verification succeeds but signup stalls?

Start with one correlation id. It must appear in the signup response, email job, verification request, and final account event. Do not use the email address as the id; aliases, case folding, and retries make it a poor key.

The common failure is a split brain between browser and server. The browser receives a 202 from POST /signup, displays a check-your-inbox message, then reloads and loses the pending token. Another version creates the user before verification while the UI waits for a verified flag on a different record. Both paths look like delivery problems to support. They are state problems. A 409 from the final account endpoint is useful evidence: the account row and the verification attempt disagree.

Ship less.

Write the allowed transitions down before touching the mail provider:

created -> captcha_passed -> code_sent -> code_accepted -> account_active

code_expired and locked are terminal for that attempt, not silent retries. A resend creates a new attempt and invalidates the old one. If an old code returns success, you have an ordering bug.

A small TypeScript trace that catches the handoff

This handler stores an attempt, sends through a generic adapter, and records each transition. The adapter can point at a managed service during migration or an SMTP-compatible system later; the application contract stays the same.

type SignupState = 'created' | 'captcha_passed' | 'code_sent' | 'code_accepted' | 'code_expired' | 'locked' | 'account_active';
interface Attempt { id: string; email: string; state: SignupState; codeHash: string; expiresAt: number; }
interface Mailer { sendVerification(input: { to: string; attemptId: string; code: string }): Promise<void>; }
async function createAttempt(email: string, captchaPassed: boolean, mailer: Mailer, store: { put(a: Attempt): Promise<void> }, hash: (value: string) => string, now = Date.now()): Promise<Attempt> {
  if (!captchaPassed) throw new Error('captcha_required');
  const code = String(Math.floor(100000 + Math.random() * 900000));
  const attempt: Attempt = { id: crypto.randomUUID(), email: email.trim().toLowerCase(), state: 'captcha_passed', codeHash: hash(code), expiresAt: now + 10 * 60 * 1000 };
  await store.put(attempt);
  await mailer.sendVerification({ to: attempt.email, attemptId: attempt.id, code });
  attempt.state = 'code_sent';
  await store.put(attempt);
  return attempt;
}
Enter fullscreen mode Exit fullscreen mode

The important detail is the second write. If mail succeeds but that write does not, the next verification request must return a visible pending state and the correlation id. It must not pretend the account is ready. Add a test that kills the process between those writes; assert that retry is idempotent and cannot create two accounts.

For verification, compare attempt id, constant-time hash, expiry, and current state in one transaction. Return separate metrics for unknown_attempt, expired_code, already_used, and captcha_required. One invalid_code counter hides the branch you need.

How do migration choices change the debugging surface?

During migration, preserve the application-facing contract and move one edge at a time. Export pending attempts only if the old system exposes expiry and hash semantics; otherwise force a fresh verification rather than guessing. Users tolerate one extra code. They do not tolerate an account that appears active and then vanishes.

A managed identity workflow is the right choice when the team cannot operate rate limits, abuse review, and recovery. The catch is control: provider-specific callbacks and opaque retries can make a stalled signup harder to explain. An email-link flow fits a desktop-heavy leasing office, but shared tablets often open the wrong session.

I'm not sure every portfolio needs a custom risk score. I am sure every one needs an audit trail answering four questions: which captcha decision passed, which code was issued, which attempt accepted it, and when the account became active.

Reproduce with a fixed test mailbox and a fresh attempt id. Capture browser network logs, server transition logs, queue timestamps, and the final database row. Compare clocks; five minutes of skew can look exactly like an expired code. Check that resend throttles by account and source address, and that messages do not reveal whether an email already owns an account. OWASP recommends generic authentication responses and careful throttling.

The runner-up is to keep the managed provider for verification while moving signup orchestration into your service. Pick it when the migration deadline is close or on-call coverage is thin. Stay with a fully managed flow when audit exports, recovery policy, and abuse controls matter more than owning each transition.

References

Further reading

Top comments (0)