DEV Community

kevindev
kevindev

Posted on

Email Verification APIs Need a Replay Contract

Email verification is usually described as a short authentication flow: create a user, send a message, accept a token, and mark the address as verified. In production, the difficult part is the retry. A mobile client can resend a request after a timeout, a worker can deliver the same event twice, and a user can open the same link in two tabs.

The request can arrive twice, and the backend dont get to choose whether the network will behave nicely. An email verification API needs a replay contract: a clear definition of what repeated requests do, which state transitions are allowed, and what evidence the service returns.

Why verification retries need an explicit contract

Consider a POST /verification/confirm endpoint. It receives a token and returns success after updating the account. If two requests reach different application instances at nearly the same time, both can validate the token before either transaction records the result.

Without a database boundary, both handlers may report success, emit duplicate events, or perform follow-up work twice. Even if the user-visible result looks fine, analytics, welcome-email jobs, and audit records can drift.

There is a second case: the first request commits, but its response is lost. The client retries because it believes the operation failed. The API should not turn that normal network condition into an error that forces support work.

This is where a small contract pay off. The service can say that the first valid use changes the account, later uses return an already-completed result, and expired or malformed tokens never change state.

Model the verification state

Start with explicit states instead of a boolean that hides history:

issued -> consumed
issued -> expired
issued -> revoked
Enter fullscreen mode Exit fullscreen mode

The account can separately have an email_verified_at timestamp. Keeping token state and account state distinct helps answer two different questions: was this credential ever valid, and is the account verified now?

A token record might contain:

verification_tokens(
  id,
  user_id,
  token_digest,
  status,
  expires_at,
  consumed_at,
  created_at,
  request_id
)
Enter fullscreen mode Exit fullscreen mode

Store a digest rather than the raw token. The raw value belongs in the link sent to the user; it does not need to become a recoverable secret in the database. A request_id or correlation value is useful for tracing, but it should not contain the token itself.

This model also handles imperfect input. A test address such as temp gamil com or a note containing tem email may be useful as fixture data, but neither should be treated as proof that a verification flow succeeded. The API validates the token and the recorded state, not a string that merely looks email-shaped.

Make the REST API replay-safe

The confirmation handler should validate and consume the token in one transaction. A simplified sequence is:

  1. Hash the presented token.
  2. Lock or conditionally update the matching row.
  3. Accept the transition only when status = 'issued' and expires_at > now().
  4. Set status = 'consumed' and consumed_at.
  5. Mark the account verified if it is not already verified.
  6. Commit, then publish any asynchronous follow-up event.

The conditional update is the important part. In PostgreSQL, it can be expressed as an update that returns the row only when the transition is allowed:

UPDATE verification_tokens
SET status = 'consumed', consumed_at = now()
WHERE token_digest = $1
  AND status = 'issued'
  AND expires_at > now()
RETURNING user_id;
Enter fullscreen mode Exit fullscreen mode

If no row is returned, the handler must distinguish invalid, expired, and already-consumed cases according to the product contract. It should never silently issue a second welcome event.

For a resend endpoint, use an idempotency key tied to the client operation. The key prevents a retry from creating several active tokens. It is different from the verification token: the idempotency key describes the request, while the token proves control of the mailbox.

Use PostgreSQL to enforce the boundary

Application checks are helpful, but constraints are the final guard when several workers race. A partial unique index can limit one active token per account, depending on the product rule:

CREATE UNIQUE INDEX one_active_verification_per_user
ON verification_tokens (user_id)
WHERE status = 'issued';
Enter fullscreen mode Exit fullscreen mode

If the design allows multiple active tokens, include a generation or purpose column and define which token wins. Do not leave that choice to whichever handler happens to commit first.

Keep the account update and token consumption in the same transaction. Send the email or enqueue a durable outbox event after the state change is committed. Otherwise a rollback can leave a message pointing at a token the database no longer accepts.

The same principle applies to observability. Treat the operation as a replayable operation with a receipt, recording a request identifier, outcome, and state transition without logging the raw token. For scheduled cleanup, a durable run verdict makes expired-token jobs easier to inspect.

Return useful evidence without leaking tokens

A successful first confirmation might return:

{
  "status": "verified",
  "request_id": "req_123"
}
Enter fullscreen mode Exit fullscreen mode

A replay can return the same safe success shape, or a documented already_verified status. Consistency matters more than the exact label. Do not return whether a token digest exists when that distinction helps an attacker enumerate accounts.

A useful receipt are the request ID, token state before and after, account ID, and reason code. It is enough for debugging without putting secrets into logs. Logs should also avoid full email addresses when an account identifier or redacted address works.

Implementation checklist

  • Define legal token transitions and expiry behavior.
  • Hash verification tokens before storage.
  • Consume a token with one conditional database operation.
  • Make resend requests idempotent.
  • Use PostgreSQL constraints for the race-prone rules.
  • Commit account changes before publishing follow-up work.
  • Return stable responses for safe retries.
  • Record request IDs and reason codes, never raw tokens.
  • Test concurrent confirmation, lost responses, expiration, and duplicate delivery.

Email verification is not just a link parser. It is a small distributed system at the edge of authentication. A replay contract turns retries from surprising behavior into a supported part of the REST API, while PostgreSQL gives the contract a boundary the application can actually enforce.

Top comments (1)

Collapse
 
elijahbrown profile image
Elijah Brown •

Good framing. One line I'd add to the contract: if a replay with the same key returns the stored result, a client that genuinely wants a fresh answer (say the first attempt hit a DNS timeout) needs a new key, and the docs should say so, or people retry for ever and keep getting the cached failure.