DEV Community

kevindev
kevindev

Posted on

Make Email Verification APIs State-Driven

Email verification often starts as one column and one endpoint: store a token, set verified = true when the token arrives, and return success. That works until users click an old link, a mobile client retries after a timeout, or two requests redeem the same token at nearly the same time.

The durable approach is to model verification as a small state machine. The API then reports what happened instead of making clients infer state from a boolean or from inconsistent error messages. This also makes email fixtures, including a free disposable email address or a temp mail generator used in test environments, easier to reason about without weakening production rules.

Why a boolean verified flag is not enough

A boolean answers only one question: has this account been verified at some point? It cannot explain whether a token was issued, consumed, expired, revoked, or rejected because it belongs to another account.

Those distinctions matter for both security and operations. A user who clicks an expired link needs a different response from a user who presents a token that was already consumed. A support engineer also needs to know whether the email was never sent or whether the verification request failed later.

The state machine are useful because it gives each transition an owner and a clear outcome. A practical lifecycle might contain:

  • pending: an address was registered and verification is required.
  • sent: a verification message was requested.
  • verified: a valid token was consumed successfully.
  • expired: the token passed its deadline.
  • revoked: the token was invalidated by a password or email change.

The account state and token state should not be confused. An account can be pending while its newest token is expired, and a new token can then be issued without rewriting the history of the old one.

Model the verification lifecycle explicitly

Start with a token record that has a stable identifier, a digest rather than the raw token, an expiration time, and timestamps for each meaningful transition. For example:

CREATE TABLE email_verification_tokens (
    id uuid PRIMARY KEY,
    user_id uuid NOT NULL REFERENCES users(id),
    token_digest bytea NOT NULL UNIQUE,
    status text NOT NULL CHECK (status IN ('pending', 'consumed', 'expired', 'revoked')),
    expires_at timestamptz NOT NULL,
    consumed_at timestamptz,
    created_at timestamptz NOT NULL DEFAULT now()
);
Enter fullscreen mode Exit fullscreen mode

Do not store a reusable token in plaintext. The application can hash the value supplied by the client and compare the digest. If the database is exposed, an attacker should not receive a ready-to-use verification credential.

The token endpoint should evaluate expiry at the time of the transaction, not only in a background cleanup job. Cleanup is helpful for storage, but it is not the security boundary. A token that expired two seconds ago must be rejected even if a worker has not changed its row to expired yet.

Make the REST API idempotent

Clients retry. Networks drop responses, browsers reload pages, and a mobile app may submit the same verification request twice. A successful first request followed by a timeout must not become a confusing 409 on the second request if the account is already verified.

Use a transaction that locks the token row, checks its current status and deadline, then performs one transition. A simplified flow looks like this:

BEGIN
  SELECT token FOR UPDATE
  if token is missing or revoked: reject
  if token is expired: mark expired, reject
  if token is consumed: return the existing success outcome
  mark token consumed
  mark account verified
COMMIT
Enter fullscreen mode Exit fullscreen mode

The response can distinguish the first successful consumption from a safe replay while keeping both outcomes useful to the client:

{
  "status": "verified",
  "user_id": "user_123",
  "already_verified": false
}
Enter fullscreen mode Exit fullscreen mode

For a replay, return the same resource state and set already_verified to true, or use a documented conflict response if the product needs the distinction. What matters is that the contract is stable. The client should not guess what happend from a generic 500 response.

This is also where an idempotency key can help for the endpoint that requests a new email. Store the key with the user and operation, then return the original result when a retry arrives. Do not use the request timestamp as the key: two legitimate requests can happen in the same second.

Let PostgreSQL enforce the important invariants

Application checks are necessary, but concurrent requests can pass the same check before either one commits. PostgreSQL should enforce rules that must remain true under concurrency:

  • a token digest is unique;
  • a token belongs to one user;
  • a consumed token has a consumed_at value;
  • an account cannot have two current verification tokens if the product allows only one;
  • status values are restricted to known transitions.

A partial unique index can enforce one active token per user:

CREATE UNIQUE INDEX one_active_verification_token
ON email_verification_tokens (user_id)
WHERE status = 'pending';
Enter fullscreen mode Exit fullscreen mode

When a new token is requested, revoke the prior pending token in the same transaction before inserting the replacement. If the insert loses a race, catch the constraint error and reload the current token state rather than returning an unexplained database failure. The error handling is a bit more work, but the behavior is predictable.

For auditability, record transition events separately when the workflow is important enough to investigate. The event should include the user ID, token ID, request ID, actor type, and reason. Avoid storing the raw token or the full email body in an audit table.

Test retries, expiry, and stale tokens

The happy path is the least interesting test. Cover these cases explicitly:

  1. Two requests redeem the same token concurrently; only one transition performs the verification.
  2. The response is lost after commit; a retry returns the verified state.
  3. A token expires during the request; the transaction rejects it using the database clock.
  4. A newer token is issued; the older token is revoked and cannot verify the account.
  5. A user changes their email; tokens for the previous address no longer work.
  6. A duplicate request for a new email does not create an uncontrolled message burst.

Tests should assert both the HTTP response and the final database state. A fake inbox is useful for checking message contents, but it should not be the source of truth for whether the token was consumed. Keep the protocol identifiers separate from search phrases such as tempail or temp org mail; those terms may appear in user input, but they are not correlation keys.

When inspecting failures, designing a verification-token inbox is a useful companion pattern for isolating messages from application state. For changes that update an existing address, auditing email-change APIs with PostgreSQL covers the history that a simple token table may miss.

Questions engineers usually ask

Should expired tokens be deleted immediately?

Usually no. Retain a short history so support and security investigations can distinguish an expired token from a missing one. A scheduled retention job can delete old rows later.

Should the token endpoint return the user session?

That is a product and threat-model decision. If verification is a prerequisite for login, returning a restricted session may be reasonable. Keep token consumption and session issuance in clearly separated steps so a replay cannot mint a second session accidentally.

Do we need a queue for this flow?

The state transition does not require one. A queue is useful for sending the email and retrying delivery, while the verification endpoint should remain a small, transactional REST API. Mixing delivery retries with token redemption makes failures harder to diagnose.

An explicit lifecycle turns verification from a fragile side effect into a maintained backend contract. PostgreSQL protects the invariants, the REST API reports stable outcomes, and retries become an expected operating condition instead of a production surprise.

Top comments (0)