DEV Community

kevindev
kevindev

Posted on

State Machines for Email Verification APIs

Email verification is often represented by one boolean: email_verified = true or false. That works for a demo, but it becomes vague as soon as delivery is delayed, a user requests a second message, or a worker retries the same job.

In backend services, I prefer treating verification as a small state machine. It makes the API contract clearer, gives PostgreSQL useful invariants, and lets support explain what happened without reading application logs line by line. It also keeps a temp email generator used in development from accidentally shaping production rules.

Why a boolean is not enough

Consider a user who clicks “resend” twice. The first message may arrive after the second one. If the database only stores a boolean and one token, the older link can be accepted unexpectedly, or a valid newer link can be rejected because the record was overwritten.

The same ambiguity appears when a mail provider reports a temporary failure. Is the account unverified, queued, blocked, or expired? Those are different operational situations, and they deserve different next actions.

For a disposable email account used in a test environment, this distinction is especially useful. Test code can wait for a message that belongs to its request instead of matching whichever message happens to be newest.

Model the verification states

A practical state set is small:

  • pending: the user started verification, but no message is confirmed as delivered
  • sent: a message was accepted by the mail provider
  • confirmed: the token was used successfully
  • expired: the token lifetime ended
  • cancelled: a newer verification request replaced this one

The important part is not the exact names. It is making transitions explicit. confirmed should be terminal for that token. expired should not become confirmed merely because an old link was replayed.

The database row can carry a request id, token hash, expiry time, and timestamps for each meaningful transition. That gives you a history that is compact enough for normal traffic but detailed enough for incident review.

Make transitions idempotent

Verification links are commonly opened twice: once by a mail security scanner and again by the user. The endpoint should therefore be safe to repeat.

UPDATE email_verifications
SET state = 'confirmed', confirmed_at = now()
WHERE id = $1
  AND state = 'sent'
  AND expires_at > now()
RETURNING user_id;
Enter fullscreen mode Exit fullscreen mode

If this returns a row, the request performed the transition. If it returns no row, the API should inspect the existing state and return a stable result such as “already confirmed” or “link expired”. Do not create a fresh token as a side effect of a GET request.

For concurrent clicks, put the transition and the user update in one transaction. A row lock or a conditional update ensures that two requests cannot both claim the same token. This is one of those details that looks fussy until a high-traffic signup flow produces duplicate welcome jobs.

Store evidence that survives retries

Each verification request should have a correlation id. Include it in the email event, provider response, worker log, and API audit record. That makes it possible to connect the signup request with the email, similar to correlating deploy emails with the right run.

I usually store these fields:

Field Reason
request_id Separates resend attempts
token_hash Avoids storing the usable secret
state Represents the current contract
provider_message_id Connects delivery callbacks
expires_at Makes replay rules deterministic
created_at and transition times Explains delays

Keep the raw token out of logs. A temporary email account generator can be handy for isolated development checks, but it should not be treated as proof of a real user's identity or as a replacement for provider-level delivery signals.

A Node.js endpoint shape

The HTTP layer should translate state into predictable responses. A simplified handler might look like this:

async function confirmEmail(req, res) {
  const result = await verificationService.confirm({
    requestId: req.params.requestId,
    token: req.query.token,
  });

  if (result.kind === "confirmed") return res.status(204).end();
  if (result.kind === "already-confirmed") return res.status(204).end();
  if (result.kind === "expired") {
    return res.status(410).json({ code: "verification_expired" });
  }
  return res.status(400).json({ code: "verification_invalid" });
}
Enter fullscreen mode Exit fullscreen mode

Notice that repeated success is still a success. Clients should not need a special recovery path because a browser or scanner revisited the link. The service can publish a verification.confirmed event once, guarded by the same transaction or an idempotency key.

This event-oriented approach builds naturally on versioning email events in a Node API. Consumers can reject unknown event versions instead of silently misreading a changed payload.

Operational checklist

Before shipping, I check the following:

  1. Are resend attempts represented by separate request ids?
  2. Is the token stored as a hash and compared in constant-time code?
  3. Can two confirmations race without double-updating the user?
  4. Are expired and already-confirmed responses stable for clients?
  5. Can a provider callback be replayed safely?
  6. Do logs contain correlation ids but no usable tokens?
  7. Can a test use a dummy e mail without changing production trust rules?

The last point sounds minor, but test conveniences often leak into policy code when the state model is unclear.

Q&A

Should sent mean the user received the message?

No. It should normally mean the provider accepted the request. Delivery, bounce, and complaint signals are separate facts. Combining them hides useful failure modes.

Should expired rows be deleted?

Not immediately. Retain a limited audit record, remove or hash sensitive values, and apply a documented retention policy. A small history helps investigate resend loops and abuse.

Is a state machine overkill for a small service?

Usually not. The implementation can be a handful of guarded database transitions. The value comes from making edge cases explicit before traffic and retries make them expensive.

Email verification becomes easier to maintain when every request has an owner, a lifetime, and a legal next state. That is a modest amount of structure, but it prevents a surprising number of authentication bugs.

Top comments (0)