Email verification is often implemented as a small feature: create a token, send a message, and activate the account when the link is opened. That design works until the database commit succeeds but the email worker fails, or a client retries the request while the first message is still in flight.
The safer mental model is a transaction with an asynchronous side effect. The account state, verification attempt, and delivery request should be recorded together. A worker can then deliver the message at its own pace, while the API remains the source of truth.
Why a verification request is a transaction
A verification request changes more than one thing. It may create an account, issue a token, and enqueue an email. If those actions are independent, partial success becomes difficult to repair:
- The user exists, but no message was queued.
- Two retries create two valid tokens.
- An old link activates a newer account state.
- Support can see that an email was sent, but not which request owns it.
Treat the request as one database transaction and the delivery as an outbox event. The API commits durable intent; the worker performs the external delivery later. This is a good boundary for a better email fixture contract for CI, because tests can assert the outbox record before depending on an actual inbox.
The database model
A minimal PostgreSQL design can use three records: the user, the verification attempt, and the outbox event.
create table verification_attempts (
id uuid primary key,
user_id uuid not null references users(id),
token_hash bytea not null,
request_key text not null,
status text not null check (status in ('pending', 'used', 'expired', 'revoked')),
expires_at timestamptz not null,
created_at timestamptz not null default now(),
used_at timestamptz,
unique (user_id, request_key)
);
create table email_outbox (
id uuid primary key,
event_type text not null,
aggregate_id uuid not null,
payload jsonb not null,
sent_at timestamptz,
created_at timestamptz not null default now()
);
Store a hash of the token rather than the raw value. The raw token is returned once to the link builder, while the database only needs to compare a hash later. The request_key makes a client retry safe for one logical operation. It also gives logs a stable handle without putting an email address into every message.
The records are small, but it carry the important ownership information: which user, which request, which state, and which expiration. A unique constraint is stronger than an application-level “check then insert” because concurrent requests can race.
Write the outbox in the same transaction
In a Node.js service, the account update and outbox insert should use the same PostgreSQL transaction. A simplified flow looks like this:
await db.transaction(async (tx) => {
const attempt = await tx.oneOrNone(
`select * from verification_attempts
where user_id = $1 and request_key = $2`,
[userId, requestKey]
);
if (attempt) return attempt;
const created = await tx.one(
`insert into verification_attempts
(id, user_id, token_hash, request_key, status, expires_at)
values ($1, $2, $3, $4, 'pending', now() + interval '20 minutes')
returning *`,
[id, userId, tokenHash, requestKey]
);
await tx.none(
`insert into email_outbox (id, event_type, aggregate_id, payload)
values ($1, 'account.verification_requested', $2, $3)`,
[eventId, userId, { attemptId: created.id }]
);
return created;
});
The exact query library is not important. The important bit is that a committed verification attempt always has its delivery intent beside it. If the transaction rolls back, neither record exists. If it commits, a worker can find the outbox row after a process restart.
The worker should claim rows with a short lease or for update skip locked, send the message, and mark sent_at only after the provider accepts it. A retry may still send twice, so the event ID should be passed to the provider when that option exists. The worker can be restarted without creating a second token, and that is the part many systems miss at first.
Make the worker and API idempotent
Idempotency has two layers:
-
Request idempotency: the same
request_keyreturns the existing verification attempt. - Delivery idempotency: the same outbox event has one stable event ID and a bounded retry policy.
The API should return the same public result for a repeated request, even if the first delivery is still pending. Do not expose whether an account email exists in a public endpoint; return a generic response and log the internal result with a correlation ID.
For operators, safer redacted email debugging is a useful companion practice. Logs should include user and attempt IDs, domains when appropriate, and event status, while omitting raw addresses, tokens, and message bodies.
Token consumption and expiration
Verification should be a conditional update, not a read followed by an update:
update verification_attempts
set status = 'used', used_at = now()
where id = $1
and status = 'pending'
and expires_at > now()
returning user_id;
If this returns no row, the token is expired, already used, revoked, or unknown. The application can show one neutral error message for all cases. This keeps the state transition atomic and prevents two requests from consuming the same token.
There is no need to be clever here; boring logic is more easier to maintain. A scheduled cleanup job can mark old pending attempts as expired, but expiration must still be checked during consumption because cleanup is never perfectly punctual.
Observability and test boundaries
Track these events with a correlation ID:
- verification attempt created
- outbox event committed
- delivery claimed, sent, or retried
- token consumed or rejected
- attempt expired or revoked
Metrics should separate database failures, provider failures, and invalid-token responses. Those failure classes have different owners and different retry behavior. These checks gives a much faster diagnosis than one generic “email verification failed” counter.
In integration tests, assert the database and outbox contract first. Use a controlled inbox only for the final delivery path. A test fixture may contain search terms such as dummy e mail or tempail, but those strings are input examples, not identity keys or token values.
Implementation checklist
- Use one request key for one logical verification intent.
- Insert the verification attempt and outbox event in one transaction.
- Store token hashes, never raw tokens.
- Give every outbox event a stable ID and retry limit.
- Consume tokens with one conditional update.
- Keep expiration checks in the request path.
- Redact addresses, tokens, and message bodies from logs.
- Test the outbox contract separately from provider delivery.
Email verification becomes much easier to operate when it is modeled as a transactional workflow. PostgreSQL records durable intent, Node.js workers handle delivery, and the token state machine prevents retries from becoming new identities. The result is a flow that can be replayed, inspected, and repaired without guessing what happened.
Top comments (1)
Good breakdown of the outbox boundary. One race worth covering: the select-then-insert inside the transaction can still collide when two retries with the same request_key arrive together, so the second one hits the unique (user_id, request_key) constraint and fails instead of returning the existing attempt. Using INSERT ... ON CONFLICT DO NOTHING RETURNING and falling back to a select when no row comes back handles that without an application-level lock. On the worker, PostgreSQL's docs describe FOR UPDATE SKIP LOCKED as giving an inconsistent view and suited to queue-like tables, which is fine here, but with several workers a later outbox row can be sent before an earlier locked one, so keep per-user ordering in mind if events depend on each other.