DEV Community

kevindev
kevindev

Posted on

Idempotent Email Verification APIs Need Expiring Keys

Email verification looks small until a mobile client retries a request, a worker delivers the same message twice, or a user clicks an old link. The important question is not only whether the token is valid. It is whether the whole operation is safe to repeat.

In backend work, I treat verification as a state transition with an explicit database contract. A request should be replayable without creating several active tokens, sending an unbounded number of messages, or turning an already completed verification back into a pending state. The design is less flashy than adding another queue, but it prevents a lot of operational noise.

Why verification retries need a database contract

Consider a client calling POST /v1/email-verifications. The request can time out after the server commits, so the client retries even though the original operation succeeded. If the endpoint generates a fresh token for every call, the last email may invalidate the previous links. If the endpoint has no limit, a button double-click can become a mail sending incident.

An idempotency key gives the request a stable identity. The server stores that key with the user, the requested email, the resulting verification record, and an expiry time. A retry with the same key can return the original result. A different payload with the same key must be rejected, because silently changing the meaning of a key makes debugging much harder.

This is the same kind of boundary I use when designing other API fixtures: the request, its side effects, and its response need one durable receipt. A Playwright email assertion can verify the user-visible result, but it cannot replace the server-side uniqueness rule.

Model the verification request as an idempotent command

Keep the API model small: an email address and a client-supplied idempotency key. Return a request ID and generic status without revealing whether an account exists.

POST /v1/email-verifications
Idempotency-Key: 01JZ7KQ7Q8M6N4Y2X0P3R8T5VA
Content-Type: application/json

{"email":"person@example.com"}
Enter fullscreen mode Exit fullscreen mode

The handler should follow a predictable order:

  1. Normalize the email for the product's documented policy.
  2. Validate the idempotency key format and length.
  3. Look up an existing key for the same user and endpoint.
  4. Return the saved response if the payload fingerprint matches.
  5. Otherwise, create the verification request and its token in one transaction.
  6. Commit before handing work to email delivery.

The fingerprint matters. Comparing only the key means a buggy client could reuse it for another address and receive a misleading success response. It doesnt need the raw email; a keyed digest or scoped hash is enough for comparison.

Use PostgreSQL constraints and expiring keys

Application checks are useful, but PostgreSQL should enforce the invariant during concurrent requests. A simplified schema might look like this:

CREATE TABLE verification_requests (
  id uuid PRIMARY KEY,
  user_id uuid NOT NULL,
  email_digest bytea NOT NULL,
  token_digest bytea NOT NULL,
  status text NOT NULL CHECK (status IN ('pending', 'verified', 'expired')),
  idempotency_key text NOT NULL,
  request_fingerprint bytea NOT NULL,
  expires_at timestamptz NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (user_id, idempotency_key)
);
Enter fullscreen mode Exit fullscreen mode

The unique constraint turns a race into a recoverable conflict. Store only a token digest, not the usable token. On verification, require status = 'pending' and expires_at > now() in the update predicate. If zero rows are updated, return a generic expired-or-invalid response.

Idempotency records also need retention. Expiring keys too quickly causes a late network retry to create a second request; retaining them forever bloats the table. The right window depends on client retry behaviour, but it should be longer than the maximum expected request timeout and queue delay. A scheduled cleanup job can remove old keys after the window, while the verification request itself keeps its audit state.

Keep email delivery separate from API completion

Do not send an email halfway through a transaction and then report success only after the database commit. A crash between those steps creates an email with no durable request, or a retry that sends a second message.

The outbox pattern is a better boundary: write the verification row and an email.send event in one transaction, then let a worker deliver the message. The worker should have its own delivery idempotency key. Email providers may still retry, so the message path needs a provider receipt and a bounded retry policy.

That separation also makes rate limiting clearer. Limit new commands per user and address, then separately limit delivery attempts. A disposable email address or a throwaway email generator can help isolated staging flows, but it should not bypass production abuse controls.

Testing retries without inbox collisions

Test the state machine, not only the happy path. The minimum suite should cover:

  • two concurrent requests with one idempotency key;
  • a retry with the same payload after a simulated timeout;
  • the same key with a different email;
  • a token used twice;
  • an expired token;
  • an outbox worker retry after a provider timeout;
  • a verification request for a previously verified address.

For browser coverage, passwordless login email tests are a useful companion pattern. Give each run its own mailbox and message cursor. Never assert that “the newest message” is the right one, because parallel runs make that assumption false pretty quick.

Legacy test data sometimes contains search strings such as tepm mail com or tamp mail com. Keep those as plain fixture text, not as links or API rules. It is easy to confuse a typo keyword with a valid domain when a test is written in a hurry.

A practical checklist

Before shipping the endpoint, check the following:

  1. Is the idempotency key scoped to the user and operation?
  2. Does PostgreSQL enforce uniqueness under concurrency?
  3. Is the request fingerprint checked on replay?
  4. Are tokens hashed, single-use, and time limited?
  5. Can the API commit without waiting for the provider?
  6. Does the outbox worker expose a durable delivery receipt?
  7. Are response messages generic enough for account privacy?
  8. Are old records cleaned without deleting useful audit history?

One small detail is often missed: a verified request should remain verified when a duplicate confirmation arrives. The handler must be monotonic; it shouldnt move the record backward just because the second click used stale state. With these constraints, retries become an expected part of the system rather than an exceptional path, and the email flow stays understandable when production gets busy.

Top comments (0)