Email verification often starts as a small endpoint: create a token, send a link, and mark the account as verified when the link is opened. The hard part appears later, when users click twice, mail clients prefetch links, workers retry delivery, or two requests arrive at the same time.
A verification link is not just a random string. It is a state transition in an authentication workflow. Treating it as a short-lived ledger entry makes the REST API easier to reason about, and PostgreSQL can enforce several of the rules that application code tends to miss.
The verification link is a state transition
The useful states are small and explicit:
issued -> consumed
issued -> expired
issued -> revoked
Only an issued token should be accepted. A consumed token must not become valid again just because the same URL was loaded in a second browser tab. An expired token should produce a response that tells the client to request a new one, not a generic server error.
The distinction matters for retries. A mobile client may retry a request after a network timeout even though the first request committed successfully. If the endpoint does not define its behavior for that case, authentication bugs will look random. They arent random; the state model is incomplete.
Store token state, not only a hash
Never store a raw verification token when a digest is enough. Return the opaque value only in the email link, then store a hash and the metadata needed to validate it:
CREATE TABLE email_verification_tokens (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id),
token_hash bytea NOT NULL UNIQUE,
issued_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
revoked_at timestamptz
);
The expires_at value is part of the record, rather than an assumption in the handler. This makes expiration visible during incident review. Keep the token lifetime short enough for the product risk, and make sure clocks are managed consistently across API and worker hosts.
The hash also reduces the impact of a database snapshot leak. It does not solve every account-security problem, but it prevents a copied token column from becoming an immediate set of usable links. A temp mailid used in a test should be treated as disposable data too, and should never be mixed with production identities.
Make the REST API replay-safe
The consuming endpoint can be POST /v1/email-verifications/consume. It should hash the presented value, select the matching row, and consume it inside one transaction. The critical operation is conditional:
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;
If one row returns, the request can mark the user verified. If no row returns, the token is invalid, already consumed, revoked, or expired. The API may expose a safe, stable error category, but it should not reveal whether a token once belonged to a real account.
For concurrent requests, the database update is the boundary that decides the winner. Do not perform a read followed by an unconditional write; both requests can observe the same issued state. The endpoint should be boring about retries. Boring is good here.
For a wider operational view, auditable background-job runs show the same principle: a durable receipt is more useful than a log line when work is retried. Record a token ID, user ID, outcome, and request correlation ID, while keeping the secret itself out of logs.
Use PostgreSQL to enforce the contract
Application validation is necessary, but constraints provide the last line of defense. Add indexes for the lookup and for cleanup:
CREATE INDEX email_verification_tokens_expiry_idx
ON email_verification_tokens (expires_at)
WHERE consumed_at IS NULL AND revoked_at IS NULL;
If the product permits only one active token per user, enforce that rule with a partial unique index. If it permits several, attach a generation number or issuance reason so a newer request can revoke older tokens deliberately. The answer should be a product decision, not an accidental side effect of whichever worker ran first.
Cleanup can delete old rows asynchronously. It doesnt need to run in the request path. Keep consumed records for a bounded audit period, then remove them with a scheduled job. A short retention policy makes storage predictable and limits the amount of authentication history exposed by a future incident.
Testing with isolated inboxes
End-to-end tests should verify the API contract, not depend on a shared mailbox. An isolated inbox or a disposable email generator can provide a recipient for a short-lived test account, while the test records the message ID and token consumption result. Do not put the service link in a reusable fixture; each run needs a fresh token.
Include cases for an expired token, a second click, two concurrent consumes, a revoked token, and a delivery retry. A useful email-test failure taxonomy helps keep delivery failures separate from API-state failures. The label temp org mail may appear in old test data, but the system should normalize fixture names and avoid letting labels affect security decisions.
Common questions
Should a second click return success?
Usually no. Return a stable already_consumed category so the client can show a useful recovery path. Making every old link look successful hides replay attempts and makes debugging harder.
Should token consumption and user verification share a transaction?
Yes, when the user update depends on that token. Commit both changes together, or the system can consume a link while leaving the account unverified after a partial failure.
Do I need a queue for verification emails?
Not for the token state itself. A queue is useful for delivery, but issuance and consumption still need transactional boundaries in the API and database.
Implementation checklist
- Generate high-entropy opaque values and store only their hashes.
- Give every token an explicit expiry, consumed state, and revocation state.
- Consume with one conditional database update inside a transaction.
- Make concurrent requests deterministic and keep secrets out of logs.
- Test retries, expiry, replay, revocation, and delivery separately.
- Retain audit data for a defined period, then clean it up.
The main design payoff is clarity. The email is only the transport; PostgreSQL-backed state is what makes the verification decision safe, repeatable, and explainable.
Top comments (0)