Email verification is often treated as a side effect of account creation: send a message, wait for a link, and mark the user as verified. In a real REST API, that side effect has its own delivery delay, retries, database state, and test inbox. The design gets much easier when the email is treated as a contract with an explicit receipt.
The inbox is part of the API contract
An authentication endpoint usually has at least two independent outcomes:
- The user record is created or updated.
- A verification message is accepted for delivery.
Those outcomes should be connected by a correlation ID, not by the assumption that the newest message in a mailbox belongs to the newest request. A request can be retried by a client, a worker can retry a provider call, and a test runner can poll while an older message is still visible.
The API response can expose a non-sensitive verification_id while keeping the actual token out of logs:
{
"user_id": "8f3a4c1e",
"verification_id": "ver_01JY7K8Q",
"status": "pending"
}
The receipt become useful when every later action can refer to it. A test using a free temporary email address can wait for the matching recipient and correlation ID instead of guessing from message order. Search noise such as temp org mail or tempail should not become part of that domain model.
Model verification as a receipt
Keep a verification record separate from the user row. It should describe what was issued, when it expires, and whether it was consumed. The important fields is small and deliberately boring:
verification_id
user_id
token_hash
correlation_id
expires_at
consumed_at
created_at
Store a hash of the token, not the raw token. The raw value belongs only in the link sent to the user. A correlation_id can be copied into provider metadata or a test-only header, but it should not contain an email address or secret.
The endpoint that consumes a token should be safe to call more than once. If the token was already consumed, return a stable result such as already_verified rather than changing the user back to an unverified state. The original request may have succeeded even when the client never received its response, so retries are normal behavior.
PostgreSQL constraints that make retries boring
The database should enforce the rules that application code is likely to forget under concurrency. A simplified schema might look like this:
CREATE TABLE email_verifications (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id),
token_hash bytea NOT NULL,
correlation_id text NOT NULL,
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX one_active_verification_per_user
ON email_verifications (user_id)
WHERE consumed_at IS NULL;
Whether one active token per user is the right policy depends on the product. The useful part is deciding the policy explicitly. A retry can arrive after the original request was successful, so the row must not gets a second meaning.
When issuing a token, use a transaction to retire or replace the previous active record and insert the new one. When consuming it, update only a row that is unconsumed and unexpired:
UPDATE email_verifications
SET consumed_at = now()
WHERE id = $1
AND consumed_at IS NULL
AND expires_at > now()
RETURNING user_id;
If no row is returned, the token is expired, unknown, or already used. Do not returns a detailed reason to an unauthenticated caller; that can make account enumeration easier.
Read the message with a cursor
The mailbox client needs a message cursor or a provider message ID. “Read the latest email” is not a reliable assertion when multiple jobs share infrastructure. Filter by recipient, subject, correlation ID, and a bounded time window. Then record which message was consumed.
For browser-driven tests, the same principle applies to waiting. Use bounded email waits with a deadline and a useful failure receipt, rather than adding another fixed sleep. A test that only checks a 200 response miss the most important part of the flow: whether the right message was delivered and consumed.
Keep the test inbox scoped to the run when possible. Isolated inbox checks are especially valuable when retries and parallel jobs are involved. The inbox is test data, not a shared dumping ground.
Expiry and cleanup are part of correctness
Expiration belongs in both the token query and the cleanup process. The API must reject an expired token immediately. A scheduled worker can later delete old verification rows and remove test messages after their retention window. The cleanup worker run independently, because a request finishing successfully does not prove that every temporary resource was removed.
Keep operational records small: verification ID, user ID, state, provider message ID, and timestamps. Do not log the token, full message body, or private links. This makes incident review safer and makes it possible to answer whether the message was sent, received, and consumed.
A practical verification checklist
- Generate a cryptographically random token and store only its hash.
- Attach a correlation ID to the verification record and delivery request.
- Make issuance and consumption idempotent.
- Enforce expiry in PostgreSQL, not only in application code.
- Poll a run-scoped inbox by cursor, recipient, and correlation ID.
- Set a clear timeout and return a failure receipt from tests.
- Delete or redact message data after the retention period.
- Measure delivery latency separately from API response latency.
The last distinction matters. A fast endpoint can still hide a slow worker or an unavailable mail provider. It also make capacity problems visible before users report missing verification links.
Questions worth answering before shipping
What happens when the user requests a second link? Decide whether the first token is revoked, whether both remain valid, and how the message test identifies the newest valid receipt.
Can a replay change account state? It should not. Consumption needs an atomic condition and a stable response for already-used tokens.
Who owns failed cleanup? Name the worker, metric, and alert. A temporary inbox with no owner is just hidden state waiting to become a flaky test.
An email verification flow is more reliable when its message, token, and cleanup behavior are explicit parts of the backend design. The result is not only better tests; it is an authentication path that stays understandable when networks, clients, and workers retry at the same time.
Top comments (0)