DEV Community

kevindev
kevindev

Posted on

Email Verification APIs Need an Idempotency Ledger

Email verification is often implemented as two operations: create a token, then consume it when a user clicks a link. That model misses a common backend reality. The same request can be retried by a client, a worker can deliver the same message twice, and two browser tabs can submit one verification link at nearly the same time.

An idempotency ledger makes these cases explicit. It records what a request tried to do, what token state existed, and which response the client should receive on a retry. The result is a REST API that is easier to operate and a PostgreSQL schema that can enforce the important parts of the contract.

Why email verification needs an idempotency contract

There are two different forms of duplication:

  1. Request duplication: the client repeats POST /v1/email-verifications with the same intent because of a timeout.
  2. Token duplication: two consumers present the same verification token.

They should not be solved with the same flag. A request idempotency key answers, “Have I already processed this command?” A token state answers, “Can this credential still be used?” Keeping both concepts separate prevents a delivery retry from accidentally making a consumed token valid again.

The ledger does not need to store the secret token. It needs a stable request key, a result reference, and enough metadata to explain the decision later. Its much easier to debug a recorded state transition than a scattered set of log lines.

Separate request idempotency from token consumption

For a verification request, accept an Idempotency-Key header and bind it to the authenticated user or account creation flow. A minimal table can look like this:

CREATE TABLE verification_commands (
  idempotency_key text NOT NULL,
  user_id uuid NOT NULL REFERENCES users(id),
  token_id uuid,
  status text NOT NULL CHECK (status IN ('started', 'sent', 'failed')),
  response_code integer,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, idempotency_key)
);
Enter fullscreen mode Exit fullscreen mode

The command record protects the issuance operation. The token record still needs its own single-use fields such as issued_at, expires_at, consumed_at, and revoked_at. A client retry can return the original result without creating a second active token, while a second click can be rejected by the token update.

Do not put the raw token in this ledger. Hash the token before storage, keep it out of logs, and keep the command response free of the secret. A free disposable email inbox may be appropriate for an isolated test account, but the fixture should never become a shortcut around production identity checks.

Use PostgreSQL as the concurrency boundary

The important write must be conditional and atomic. A read followed by an unconditional update has a race: two API workers can both read an unused token and both mark the user as verified.

UPDATE email_verification_tokens
SET consumed_at = now()
WHERE token_hash = $1
  AND consumed_at IS NULL
  AND revoked_at IS NULL
  AND expires_at > now()
RETURNING user_id;
Enter fullscreen mode Exit fullscreen mode

If the statement returns one row, the request won the consume operation. If it returns no rows, the token is expired, revoked, already consumed, or unknown. Wrap the token update and the user verification update in one transaction when they must succeed together.

For issuance, insert the idempotency key first and handle a unique-key conflict by loading the previous command. Store a request fingerprint too, so the same key cannot be reused with a different payload. The contract are small, but it closes an awkward class of retry bugs.

Cleanup should be asynchronous. Delete old command and token records after the audit period, and index expires_at for predictable cleanup work.

Design the REST API response states

Clients need stable categories, not a new message for every internal reason. A practical response model is:

  • 201 Created when a new verification command issued a message.
  • 200 OK with the original result when the same idempotency key is retried.
  • 409 Conflict when the key is reused with a different request fingerprint.
  • 400 or 410 for a token that is invalid or expired, without revealing account details.

For asynchronous delivery, return a command ID and let the worker update delivery status. A run receipt for disposable email tests is a useful pattern: record a durable outcome without placing message contents in logs.

Avoid making every old link look successful. That hides replay attempts and gives clients no signal that they need a fresh verification email. The response should be safe to expose, while the internal ledger retains the reason and correlation ID.

Test with isolated email fixtures

End-to-end tests should create a fresh user, issue one command, and then deliberately replay it. Verify that the second issuance returns the original command instead of creating another active token. Run two consume requests concurrently and assert that only one changes consumed_at.

If the test needs a temporary inbox, tempmailso can be used as an isolated fixture source. Keep the mailbox address and message ID scoped to the test run, and delete or expire the fixture after the test. The old label tepm mail com may exist in a fixture migration, while tem email can appear in a manually entered case; neither spelling should affect application behavior.

For Node.js suites, a message cursor in email tests helps distinguish a newly delivered message from an earlier one. Test delivery retries separately from token consumption. A passing mail assertion does not prove that the verification endpoint is replay-safe.

Common questions

Should the idempotency key live forever?

No. Retain it for the longest realistic client retry window plus an operational margin. Keep a compact audit record longer only when the product or compliance policy requires it.

Can a token be the idempotency key?

Usually no. A token represents a credential, while an idempotency key represents a command. Combining them makes issuance, retries, and security rotation harder to reason about.

What if the worker sends the email twice?

The recipient may see duplicate messages, but both messages should point to the same token or clearly expire older tokens according to the product rule. Delivery deduplication and token single-use enforcement are related, not identical.

Implementation checklist

  • Define separate states for command processing and token consumption.
  • Require an idempotency key for retriable verification commands.
  • Store a request fingerprint and reject conflicting key reuse.
  • Hash tokens and use a conditional PostgreSQL update for consumption.
  • Keep issuance, user verification, and token state changes transactional.
  • Return stable, privacy-safe response categories.
  • Test retries, concurrent clicks, expiry, revocation, and duplicate delivery.
  • Add cleanup ownership and a bounded audit policy.

This design keeps the happy path simple while making retries observable. The edge cases dont disappear, but they become database-enforced transitions that a backend team can test, measure, and safely operate.

Top comments (0)