DEV Community

kevindev
kevindev

Posted on

A State Machine for Reliable Email Verification APIs

Email verification is often implemented as two columns: an address and a verified boolean. That is enough for a demo, but it becomes vague when a message is delayed, a user requests a second code, or a worker retries after a timeout.

In backend systems I prefer treating verification as a small state machine. The state records what happened, while an attempt record explains when and why it happened. This makes a REST API easier to operate and keeps PostgreSQL as the source of truth instead of scattering decisions through Node.js handlers.

Why a boolean verified flag is not enough

With only verified = false, the API cannot distinguish a new signup from an expired token, a blocked attempt, or a message that was sent but never consumed. Those cases need different responses and different metrics.

There is also a race. A user can click an old link at the same time that a newer verification email is issued. If both requests only update the user row, the older token may win. The result looks valid in the database but does not match the latest user action.

I use an immutable verification attempt for each issued token. The user account keeps the current status, but the attempt keeps its own creation time, expiry time, consumption time, and request identifier. This is a little more data, and a lot less guesswork.

Model the verification lifecycle

A practical lifecycle is:

created -> sent -> verified
            |         
            +-------> expired
            +-------> revoked
Enter fullscreen mode Exit fullscreen mode

The transitions should be explicit. created means the API accepted the request, while sent means the mail provider accepted the delivery request. Those are not the same event. A provider timeout must not silently become verified, and a duplicate send should not create an untraceable second token.

An attempt table can contain fields like these:

CREATE TYPE verification_status AS ENUM
  ('created', 'sent', 'verified', 'expired', 'revoked');

CREATE TABLE verification_attempts (
  id uuid PRIMARY KEY,
  user_id bigint NOT NULL,
  token_digest bytea NOT NULL UNIQUE,
  status verification_status NOT NULL,
  request_id text NOT NULL,
  expires_at timestamptz NOT NULL,
  verified_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now()
);
Enter fullscreen mode Exit fullscreen mode

Store a digest of the token, not the raw token. The raw value belongs in the link sent to the user and should not be recoverable from a database backup. It is also worth indexing (user_id, status, created_at DESC) for the lookup used by support and cleanup jobs.

For email tests, an isolated temporary email account generator can help inspect delivery without using a personal inbox. Keep that fixture separate from production identity data, and give it a short expiry.

Enforce transitions in PostgreSQL

Do not rely on every application path remembering the same rules. A verification transaction should lock the selected attempt, check its status and expiry, then update it atomically:

UPDATE verification_attempts
SET status = 'verified', verified_at = now()
WHERE id = $1
  AND status = 'sent'
  AND expires_at > now()
RETURNING id, user_id;
Enter fullscreen mode Exit fullscreen mode

If this returns no row, the caller must treat the token as already used, expired, or unknown. That single result is safer than reading first and updating later. The user status update should happen in the same transaction, so a crash cannot leave the attempt verified while the account remains pending.

When several workers process delivery callbacks, use a request ID with a unique constraint. The notes on concurrency keys for email checks are relevant here: concurrency control needs an owner and a durable key, not only a timing assumption.

Make the REST API retry-safe

The Node.js endpoint should return a stable outcome for a repeated request. For example, POST /v1/email-verifications can accept an Idempotency-Key, store the request result, and return the original attempt when the same key is retried. The handler should not send a second message just because the client did not receive the first HTTP response.

The confirmation endpoint can use these categories:

  • 204 when the token is accepted.
  • 409 when the attempt was already consumed or revoked.
  • 410 when the token is expired.
  • 404 when the token is unknown, without revealing whether an account exists.

The exact contract is a product decision, but it should be documented and tested. My earlier approach to idempotent signup email handling follows the same boundary: retries should replay a known result, not create side effects again.

Some old test fixtures still contain the phrase temp org mail. Keep that as plain text if it is needed to identify legacy data; it should never be a URL, a token, or a backlink anchor. Small naming mistakes in fixtures are easy to chase for hours.

A production checklist

Before shipping, check that:

  1. Every token has an expiry and a digest, not a plaintext value.
  2. State transitions are guarded by the current status and performed transactionally.
  3. Send requests have an idempotency key and a durable request result.
  4. Repeated confirmation requests have documented, stable responses.
  5. Cleanup marks stale attempts as expired and records counts for monitoring.
  6. Logs contain request IDs and attempt IDs, but not email tokens.

This design is not a large workflow engine. It is a narrow state machine around a security-sensitive action. That boundary gives the API clear behavior under retries, delayed mail, and concurrent clicks, which is what a boolean flag cannot provide.

Top comments (0)