DEV Community

daxharrington5274
daxharrington5274

Posted on

Registration State Machine — Creating Users, Delivering Email Codes, Verifying Safely

Adding phone one-time-code login to an existing app sounds like a form change. It is a state change with a delivery system attached. The operational constraint is that a user can request the same code three times while the mail provider, database, and browser all disagree about what happened.

Short answer: create a pending identity first, attach a single-use verification challenge to it, and activate the account only after an atomic, time-bounded check succeeds.

That ordering keeps session security ahead of signup friction. It also gives support and operators a trail they can inspect without reading the code itself. I measure the path in transitions and retries, not in clicks.

The state machine I would ship

Use explicit states rather than a nullable verifiedAt column that tries to describe every failure. A compact model is enough:

State Meaning Allowed next transition
pending Identity record exists, no valid challenge has been accepted code_sent, expired, cancelled
code_sent A challenge is active and delivery was requested verified, expired, code_sent (new challenge)
verified Email ownership was proven active
active Login may create a normal session suspended
expired The challenge window ended code_sent (new challenge)

The transition log should carry an idempotency key, actor (user or worker), and a reason. Store a hash of the code, never the code. A 10-minute expiry is a useful starting point, but tune it against delivery latency and abuse data; your mileage may vary.

The key invariant is simple: active implies a successful verification event for the current identity version. A resend increments that version, so an old message cannot win a race with a newer one.

What should user creation, email code delivery, and verification guarantee?

User creation and message delivery are different transactions. The database can commit while a queue is unavailable, or a queue can accept a job while the request times out. Treating them as one synchronous operation creates phantom users and duplicate messages.

The request handler writes the pending user and an outbox row in one transaction. A worker claims the outbox row, sends the message, then records delivery metadata. If the worker retries after a timeout, the provider request needs its own idempotency key. “Sent” means accepted by the delivery boundary, not read by a human.

Here is the smallest useful transition function. It is deliberately boring; boring code is easier to audit.

type Status = "pending" | "code_sent" | "verified" | "active" | "expired";

type Event = {
  userId: string;
  version: number;
  status: Status;
  codeHash?: string;
  expiresAt?: number;
};

function acceptCode(event: Event, suppliedCode: string, now: number): Event {
  if (event.status !== "code_sent") throw new Error("invalid_state");
  if (!event.expiresAt || now >= event.expiresAt) {
    return { ...event, status: "expired" };
  }
  if (!constantTimeEqual(hash(suppliedCode), event.codeHash ?? "")) {
    throw new Error("invalid_code");
  }
  return { ...event, status: "verified" };
}

function hash(value: string): string {
  return createSha256(value); // implementation supplied by the runtime crypto module
}

function constantTimeEqual(left: string, right: string): boolean {
  return timingSafeCompare(left, right);
}
Enter fullscreen mode Exit fullscreen mode

The database update around acceptCode must check both status = 'code_sent' and the expected version. If two requests arrive together, one commits and the other gets a clean conflict. Do not turn that conflict into a 500; return a generic verification response and log the reason internally.

Failure modes that look harmless in review

The common bug is account activation before proof. A handler inserts a user, calls the mail API, and flips verified when the API returns. That proves transport acceptance, not mailbox control. The second bug is a code comparison that leaks timing or accepts a previous version. The third is an unbounded resend endpoint that becomes a cost and reputation problem.

Rate limits need more than an IP key. Combine identity, device signal, and network reputation, then keep the response text identical for unknown and known addresses. OWASP recommends generic authentication responses so an attacker cannot enumerate accounts. It also recommends allowing password managers and avoiding needless composition rules; those principles apply to code entry screens too.

I keep four counters: challenges created, delivery accepts, verification successes, and verification rejections. A spike in delivery accepts with flat successes points to inbox placement or user confusion. A spike in rejections with normal delivery suggests replay, clock skew, or a client parsing error. These signals are more actionable than a single “signup failed” metric.

Short codes are easy to type and easy to brute-force. Six decimal digits provide 1,000,000 possibilities before rate limits; that number is not a security argument by itself. Enforce attempt caps per challenge, expire aggressively, and invalidate on success. Bind the eventual session to the verified user version so a token minted before verification cannot be upgraded in place.

There is a real trade-off:

Decision Lower friction Stronger control
Code lifetime 15 minutes 5 minutes
Attempts 10 per challenge 5 per challenge
Resends Immediate Cooldown with a visible timer
Device memory Remember browser Require a fresh challenge

The catch is that the strict column is not suitable for shared inboxes, delayed enterprise mail, or accessibility flows that need more time. Stick with a longer window and a support-reviewed recovery path when those constraints dominate. Never silently extend an expired challenge because the user kept the tab open.

At scale, I would add a small event-sourcing layer for security events, a dead-letter queue for delivery jobs, and a repair command that replays only idempotent transitions. I would not add a framework-specific auth abstraction until the invariants are covered by tests. The glue cost shows up later as configuration nobody can explain.

A test matrix that catches the expensive bugs

Test transitions as a matrix, not as a happy-path script. Include duplicate submit, resend racing with submit, worker retry after a provider timeout, expired code with a correct hash, and a code from the previous version. Assert that exactly one verification event exists and that no session is issued from pending or code_sent.

Run property tests over random event orderings. For a fixed user version, no sequence may reach active without verified. I am not sure every provider exposes delivery events with the same semantics, so record the provider's acknowledgement separately from your own code_sent transition and document the difference.

This design is vendor-neutral by construction: SMTP, a transactional email API, or a self-hosted relay can sit behind the outbox worker. The application owns the state machine, expiry, replay defense, and session boundary. Swapping the delivery adapter should not require changing those rules.

Sources

References:

Top comments (0)