Email verification looks small until a service must handle retries, delayed delivery, duplicate clicks, and automated tests. The usual implementation sends a message, stores a token, and hopes the user clicks it once. That model breaks down.
I prefer treating the verification mailbox as a lease. A lease has an owner, an expiry time, a purpose, and a clear release or consumption rule. Thinking in those terms gives a Node.js REST API enough structure to behave predictably when the client retries or the email provider is slow.
Why a verification mailbox is a lease
The address used for a verification flow should be associated with one account and one attempt. It should not be a shared bucket where any recent message can satisfy any request. Otherwise a delayed message can verify the wrong session.
The lease can be represented with four values. This small contract is easy to skip during a hurried deploy, but it keeps the workflow deterministic:
-
lease_id: unique identifier for this verification attempt -
user_id: the account that owns the attempt -
expires_at: the last time the attempt may be consumed -
status:pending,consumed, orexpired
The disposable email generator or other test mailbox is only a delivery dependency. It is not proof that the token belongs to the current request. That proof should come from the database record and the token hash.
For CI, each run should have an isolated inbox. This makes it easier to connect a message to a test. See these low-noise CI email checks for treating email automation as an observable pipeline step.
The database contract
Do not store the raw verification token. Store a hash, just as you would store a password reset token. A compact PostgreSQL table might look like this:
CREATE TABLE email_verification_leases (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id),
token_hash bytea NOT NULL,
status text NOT NULL CHECK (status IN ('pending', 'consumed', 'expired')),
expires_at timestamptz NOT NULL,
consumed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX verification_leases_active_idx
ON email_verification_leases (user_id, expires_at)
WHERE status = 'pending';
The partial index keeps the common lookup focused on active leases. The expiry check still belongs in the query, because an index does not change the meaning of time. When creating a new lease, invalidate an older pending lease or explicitly select the current attempt.
That decision matters for retries. A second POST /verification/send should not create five valid tokens because a browser retried after a timeout. The request needs an idempotency key, or the service needs a deduplication window based on user and purpose.
A Node.js endpoint with idempotent behavior
The endpoint can accept an idempotency key and keep its side effects inside a transaction. The example leaves the mail provider behind an interface so the database decision is testable without sending a real message.
app.post('/v1/email-verification', async (req, res, next) => {
const userId = req.auth.userId;
const key = req.header('Idempotency-Key');
if (!key) {
return res.status(400).json({ error: 'idempotency_key_required' });
}
try {
const result = await db.transaction(async (tx) => {
const previous = await tx.verificationRequest.findByKey(userId, key);
if (previous) return previous.response;
const lease = await tx.verificationLease.createPending({
userId,
ttlSeconds: 900,
});
const response = { leaseId: lease.id, expiresAt: lease.expiresAt };
await tx.verificationRequest.saveResponse(userId, key, response);
await mailer.enqueueVerification(userId, lease.id);
return response;
});
return res.status(202).json(result);
} catch (error) {
return next(error);
}
});
In a real service, the mail enqueue operation needs an outbox record in the same transaction. Calling a provider directly before the transaction commits can produce a message for a lease that was rolled back. The outbox worker can claim unsent records, publish them, and record a delivery attempt.
The request replay behavior is worth keeping boring. The same key returns the same lease response, even if the first HTTP response was lost. These CLI guardrails for signup email checks are useful background for applying the same discipline to automated verification flows. It is occassionally tempting to just resend, but that creates a dependancy on timing.
Polling and evidence at the API boundary
Consumers should poll for a message with a stop rule, not forever. Record the lease id, inbox address, trigger time, message id, and why a message was accepted. If no message arrives before expiry, the failure should say whether delivery was late, the subject was wrong, or ownership evidence was missing.
This also prevents a common debugging shortcut: searching for phrases such as fake e mail com and then opening the newest message by hand. That can hide a routing bug while making the test appear intermittently healthy. Exact lease ownership is stronger evidence than a familiar subject line.
Operational checks for production
Before shipping the flow, I check the following:
- Token values are hashed and never written to ordinary logs.
- Expired and consumed leases cannot be used a second time.
- A unique idempotency key prevents duplicate send side effects.
- The outbox has retry limits and a visible dead-letter state.
- Metrics separate API acceptance, provider handoff, delivery, and token consumption.
- Test inboxes are isolated per run and cleaned up after the lease ends.
It is usefull to keep those stages separate in dashboards. A successful 202 Accepted only proves that the API accepted work; it does not prove that the email arrived or that the token was consumed. The metrics should stay seperated even when the first version of the dashboard is small.
For development, a temp mail so address can help check a flow manually, but it is not an authentication factor. Keep production verification tied to the user-controlled address and enforce the same token rules everywhere.
Q&A
Should every resend create a new token?
Usually, no. Within a short window, return the active lease or deliberately invalidate it before creating a replacement. Either policy is fine if it is explicit and observable.
How long should a lease last?
Choose a duration that matches the product journey and provider latency. Fifteen minutes is a reasonable starting point for many signup flows, but the important part is enforcing expires_at on consumption rather than trusting the client.
Can I use the same inbox across parallel tests?
Only with strong message ownership and a deterministic claim rule. Separate inboxes are simpler, cheaper to troubleshoot, and usually worth the small setup cost.
The central idea is modest: email verification is a state transition, not a message-shaped side effect. Model ownership and expiry in PostgreSQL, make retries idempotent in Node.js, and preserve delivery evidence. Correctness no longer depends on which email arrives last.
Top comments (0)