Email verification is usually described as a send operation: create a token, send an email, and return success. In a real backend, that description hides the hardest part. The application must know which token is current, whether it has been consumed, and what to do when a client retries while the mail provider is slow.
I prefer treating verification as an inbox with a small, explicit state machine. The REST API writes an intent to PostgreSQL, a worker delivers the message, and a confirmation endpoint consumes the token exactly once. This separates authentication state from provider behaviour and makes failures easier to explain.
The same boundary is useful in staging. A burner email can help a developer inspect a real confirmation message, but test systems still need an isolated record and a cleanup policy. A string such as temp org mail may appear in old fixtures, so it is worth keeping fixture naming seperate from production identity data.
Why verification tokens need an inbox
An email provider call has an uncertain result. The connection can fail before the response arrives, or a worker can crash just after sending. If the API only stores a token in memory, a retry may create a second valid token and leave the user unsure which message to trust.
An inbox record gives each request a durable identity. It can contain:
verification_id
user_id
token_digest
status: pending | sent | consumed | expired
attempt_count
expires_at
created_at
Store a digest instead of the raw token. The raw value belongs in the email link; the database only needs to compare a hash supplied by the confirmation request. This limits the impact of a database read leak, and it keeps the token lifecycle visible to operators.
Model the token lifecycle explicitly
There are three decisions that should not be mixed together:
- Issuing creates a new verification attempt.
- Delivering moves an attempt through the mail provider workflow.
- Consuming accepts a token once and changes the account state.
For an account that requests another message, mark older pending tokens as superseded before inserting the new one. The client can safely ask for a resend, while the server makes only the newest token valid. This is less suprising than accepting several links and hoping the user clicks the intended one.
I also put a short expiration on the token and a longer retention period on the audit row. The token should stop working quickly; the operational record can remain long enough to answer support questions without retaining the secret itself.
Claim tokens safely in PostgreSQL
A worker can claim pending deliveries with a transaction and row locking. FOR UPDATE SKIP LOCKED lets multiple workers scan the same queue without waiting on rows already owned by another worker.
WITH next_delivery AS (
SELECT id
FROM verification_deliveries
WHERE status = 'pending'
AND available_at <= now()
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE verification_deliveries AS d
SET status = 'sending',
lease_until = now() + interval '60 seconds',
attempt_count = attempt_count + 1
FROM next_delivery
WHERE d.id = next_delivery.id
RETURNING d.*;
The lease is important because a process may die while sending. A reaper can return rows with an expired lease to pending, with a retry limit and backoff. Do not hold the database transaction open while calling the provider; claim the row, commit, send, then record the result.
For deeper audit concerns, I keep the write path consistent with the PostgreSQL audits for email change APIs. The key idea is that delivery history is append-friendly evidence, not a mutable guess about the current account state.
Make the REST API idempotent
The issue endpoint should accept an idempotency key supplied by the client. Repeating the same request returns the existing verification attempt instead of creating another one. The database needs a unique constraint scoped to the account and key, plus a clear policy for an expired key.
The confirmation endpoint has a different contract:
POST /v1/email-verifications/confirm
Idempotency-Key: confirm-8f2...
Content-Type: application/json
{"token":"..."}
In one transaction, find the unexpired digest, lock it, verify the hash, mark it consumed, and update the account. A second request with the same token should return a stable result such as already_confirmed, not perform the state change twice. The response can be boring; boring is good for clients and support tooling.
When the provider response is ambiguous, the worker should retry according to a bounded policy. The authentication API should not pretend that a message was delivered merely because a job was claimed. That distinction prevents a green API response from hiding a dead inbox.
Test the failure paths
Unit tests cover token hashing and expiration, but the valuable checks are integration tests around transaction boundaries. Force a worker crash after claiming a row, then verify that the lease expires and another worker can retry it. Run two confirmation requests concurrently and assert that only one transaction consumes the token.
It is also useful to preserve a small receipt for each CI run. The approach described in these useful CI receipts for API tests makes it easier to tell a provider timeout from a database race. In my experience, this is where flaky email tests become diagnosable instead of just re-run until green.
A small production checklist
- Hash tokens at rest and never log the raw value.
- Enforce one current token per account, or document why not.
- Add expiration, attempt limits, and delivery leases.
- Use idempotency keys for issue and confirmation requests.
- Make token consumption a single transactional state change.
- Track provider ambiguity separately from API acceptance.
- Test concurrent confirmation and worker recovery.
An email verification flow becomes much easier to maintain when the inbox is treated as a backend component, not as a side effect of an HTTP request. PostgreSQL gives the system durable state, the REST API gives clients a predictable contract, and the worker can retry without inventing a new identity each time. There are still edge cases, but they are named edge cases now—and that is a good place to start.
Top comments (0)