Email verification looks like a simple two-request feature: create a user, send a code, then accept the code. In a real REST API, the hard part is the boundary between those requests. Email delivery can be delayed, clients retry after a timeout, and a user can open two verification links at nearly the same time.
I model that boundary as a short-lived verification lease. The lease gives one verification attempt an owner, an expiration time, and an idempotency key. This makes retries boring: the API can return the same result for the same operation instead of creating more codes or moving the account through an unexpected state.
Why email verification needs a lease
A verification token is not only a secret. It is also a claim about state: which account requested it, when it was created, whether it was consumed, and which request is allowed to consume it. Without those fields, a delayed message can be accepted after a newer request has already changed the account.
The lease should contain at least:
-
account_idand a hash of the verification token - an idempotency key for the request that created it
-
created_at,expires_at, andconsumed_at - the current status, such as
pending,verified, orexpired - a delivery reference for support and retry diagnosis
This is useful even when the message goes to a free temporary email inbox during an integration test. The test address does not make the workflow deterministic; ownership and time boundaries do.
Teams often search for “email temporary free” when they are trying to set up a quick test. That can solve the mailbox part, but it does not solve replay protection. A test should still prove that an old token is rejected and that a repeated request has a stable response.
For related design context, OTP inbox boundaries are a useful way to think about separating mailbox ownership from authentication state.
The REST API contract
The endpoint that creates a verification lease should accept an idempotency key:
POST /v1/accounts/42/verifications
Idempotency-Key: signup-42-3f6b
Content-Type: application/json
{"delivery":"email"}
On the first request, the service creates the lease and queues a message. On a retry with the same key, it returns the stored response for that lease. It should not generate another token just because the first HTTP response was lost.
The response can expose a non-secret reference and the expiration time:
{
"verification_id": "ver_8d2f",
"status": "pending",
"expires_at": "2026-10-21T18:00:00Z"
}
The confirm endpoint should have equally strict rules. A successful confirmation moves pending to verified once. A repeated confirmation can return the already-verified state, while a different token must fail without revealing whether another token exists.
Persisting idempotency in PostgreSQL
The database is the right place to serialize the state transition. An in-memory lock disappears during a restart and does not coordinate multiple API instances.
CREATE UNIQUE INDEX verification_request_key
ON verification_leases (account_id, idempotency_key);
CREATE UNIQUE INDEX one_active_lease_per_account
ON verification_leases (account_id)
WHERE status = 'pending';
The create handler can insert with ON CONFLICT, then read the existing row and return its stored response. The confirm handler should lock the selected row, check the expiry, compare the token hash, and update the status in one transaction.
Do not store the raw token. Hash it before persistence, and keep the token out of application logs, metrics labels, and exception messages. The database record needs enough information to explain a failure, not enough information to impersonate the account.
There is a small detail that gets missed: the outbox event should be committed with the lease. Otherwise the API can report pending while the delivery job never receives a durable event. A worker may retry delivery, but it must keep the same verification id and lease metadata.
Retry and expiration behavior
Separate three kinds of retry. A client retry with the same idempotency key should return the same lease. A delivery retry should reuse the same token and message reference where the provider supports it. A user retry after expiry should create a new lease, not revive the old one.
The expiry check belongs in the confirmation transaction, not only in a background cleanup job. Cleanup is eventually consistent; authorization cannot be. A cleanup worker can mark old rows as expired and remove message data later, but the API must reject an expired lease immediately.
This is also where a failure taxonomy helps. A missing message, an expired lease, a provider rejection, and an invalid token are different failures with different owners. The failure taxonomy for email tests offers a practical testing perspective for keeping those cases distinct.
Operational checklist
- Require an idempotency key on lease creation.
- Put a unique constraint around the account and key.
- Hash tokens and redact them from logs.
- Check expiration inside the confirmation transaction.
- Commit the outbox event with the lease row.
- Keep delivery, verification, and cleanup metrics separate.
- Test duplicate requests, delayed messages, and concurrent confirmations.
- Keep
temp org mailas plain test text only; it is not a safe identifier or an anchor.
Five minutes spent on the state model usually saves hours of debugging later. The useful signal is not merely that an email arrived. It is that the right lease accepted one valid transition, exactly once, with evidence that can be inspected after the test runner has gone away.
Q&A
Should a retry create a new verification email?
Not when it carries the same idempotency key. Return the existing lease and let the delivery layer decide whether a resend is needed. A new user action can use a new key and a new lease.
Can Redis replace PostgreSQL for this state?
Redis can help with short-lived coordination, but the durable account state and idempotency result belong in a transactional store. Using both is reasonable when their responsibilities are explicit.
What should an integration test assert?
Assert the API state, message ownership, token expiry, duplicate-request response, and final database transition. The inbox is evidence in the flow, not the source of truth.
Top comments (0)