Node.js Email Verification Needs Atomic Tokens
Email verification looks like a small feature: create a token, send a message, and accept the token when the user clicks it. In a real Node.js service, the difficult part is not generating the token. It is deciding what happens when the user clicks twice, the link expires during a retry, or two API workers process the same request at nearly the same time. The reliable design is to treat…
Email verification in Node.js applications is a simple process: generate a token, send an email, and confirm the user's identity by clicking the link. However, the real challenge lies in handling potential issues when users click the link multiple times, the link expires during retries, or multiple API workers attempt to process the same request simultaneously.
The recommended solution is to treat verification as a state transition, backed by a single atomic database operation. The email serves only as a delivery mechanism, while PostgreSQL remains the authoritative source for determining a token's validity and consumption status.
A common mistake in naive implementations is the following sequence:
1. Read the verification row.
2. Verify that `used_at` is empty and `expires_at` is in the future.
3. Mark the row as used.
4. Activate the user.
Two concurrent requests can complete step 2 before either request reaches step 3, resulting in the same token being accepted twice. This can lead to duplicate welcome messages, repeated audit events, or an account activation even when it should have failed. Similar ambiguity can arise from email delivery, where temporary or disposable email addresses may cause delayed messages or duplicates.
To mitigate these issues, it is advised to store a cryptographic digest of the token instead of the raw value in the database. This approach ensures that even if a database snapshot is exposed, the attacker will not have usable verification links.
The recommended database schema includes a table called `email_verification_tokens` with columns for the token digest, user ID, expiry time, and consumption time. A partial index is created to optimize common lookups for tokens that can still be consumed. A cleanup job can be used to remove old rows, but this process should never decide the validity of a current request.
When consuming the token, a conditional UPDATE statement is used to check the digest, expiry, and unused state while acquiring a row lock. Only one successful request should be able to update a row, ensuring that the token is accepted only once. If the activation fails, the entire transaction should be rolled back to prevent the user from receiving a "try again" response while their only token is already consumed.
The Node.js code should treat an empty result from the query as a normal rejection, rather than a database error. This way, internal failures can be logged with a safe reason, while the public response remains broad and consistent.
Written by urgent.news from Dev.to's reporting — not their text. Machine-written — may contain errors; check the original before relying on it.