DEV Community

kevindev
kevindev

Posted on

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 verification as a state transition backed by one atomic database operation. The email is only the delivery channel. PostgreSQL remains the authority for whether a token is valid and whether it has already been consumed.

The race behind a familiar verification bug

A naive endpoint usually does this:

  1. Read the verification row.
  2. Check that used_at is empty and expires_at is in the future.
  3. Mark the row as used.
  4. Activate the user.

Each step seems fine in isolation. The problem is that two requests can both finish step 2 before either request reaches step 3. A double-click, a browser retry, or two load-balanced workers can then accept the same token twice. The result may be duplicate welcome actions, repeated audit events, or an account activated by a request that should have failed.

Email delivery creates a similar ambiguity. A disposable temporary email inbox or a create temp mail workflow may expose delayed messages, duplicates, or an old verification link. Test coverage should therefore assert both token behavior and message selection. Parallel email test isolation is useful context when several workers exercise the same signup flow.

Store a digest, not the raw token

The URL can contain a random, single-use value, but the database does not need to store that value directly. Store a cryptographic digest instead. If a database snapshot is exposed, the attacker should not immediately have usable verification links.

import { createHash, randomBytes } from "node:crypto";

export function issueToken(): { raw: string; digest: string } {
  const raw = randomBytes(32).toString("base64url");
  const digest = createHash("sha256").update(raw).digest("hex");
  return { raw, digest };
}
Enter fullscreen mode Exit fullscreen mode

Store the digest, user ID, expiry time, and consumption time in a dedicated table. Keep the raw value only long enough to construct the link and hand it to the mail provider. Do not log it, even at debug level. That logging habit is easy to miss and painful to remove later.

CREATE TABLE email_verification_tokens (
  token_digest text PRIMARY KEY,
  user_id bigint NOT NULL REFERENCES users(id),
  expires_at timestamptz NOT NULL,
  used_at timestamptz,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX email_verification_tokens_active_idx
  ON email_verification_tokens (token_digest, expires_at)
  WHERE used_at IS NULL;
Enter fullscreen mode Exit fullscreen mode

The partial index is not a security boundary, but it keeps the common lookup focused on tokens that could still be consumed. A cleanup job can remove old rows later; cleanup should never decide whether a current request is valid.

Consume the token in one PostgreSQL statement

The key operation is a conditional UPDATE. It checks the digest, expiry, and unused state while taking the row lock. Only a request that updates one row has won the token.

UPDATE email_verification_tokens
SET used_at = now()
WHERE token_digest = $1
  AND used_at IS NULL
  AND expires_at > now()
RETURNING user_id;
Enter fullscreen mode Exit fullscreen mode

In Node.js, treat an empty result as a normal rejected verification, not as a database error. The query can return zero rows because the token is unknown, expired, or already consumed. Those causes can be logged internally with a safe reason, while the public response stays deliberately broad.

After the token is consumed, activate the user in the same transaction. If activation fails, roll back the token update too; otherwise the user could receive a “try again” response while their only token is already gone.

await client.query("BEGIN");
try {
  const result = await client.query<{ user_id: number }>(consumeSql, [digest]);
  if (result.rowCount !== 1) {
    await client.query("ROLLBACK");
    return { kind: "rejected" as const };
  }

  await client.query(
    "UPDATE users SET email_verified_at = now() WHERE id = $1",
    [result.rows[0].user_id],
  );
  await client.query("COMMIT");
  return { kind: "verified" as const };
} catch (error) {
  await client.query("ROLLBACK");
  throw error;
}
Enter fullscreen mode Exit fullscreen mode

The transaction must use the same connection for every statement. Returning a client to the pool between BEGIN and COMMIT breaks the boundary, a mistake that can be suprisingly hard to spot in local tests.

Make the REST API state machine explicit

The endpoint should expose a small, documented contract:

  • 200 OK when this request verifies the account.
  • 400 Bad Request when the token format is invalid.
  • 410 Gone when a well-formed token is expired or already consumed, if that distinction is useful to the client.
  • 500 Internal Server Error only for an actual service failure.

Do not let the client infer success from a redirect alone. Return a stable response body and an internal request ID for support diagnostics. If the product wants repeated clicks to be harmless, it can render the same confirmation page after a 410; that is a UX choice, not a reason to accept the token a second time.

When testing the mail side, assert recipient, run marker, subject, and link before consuming the token. Matching the intended verification message matters because an old message can contain a perfectly valid-looking URL for a different test run.

Test expiry and concurrent clicks

The high-value tests are small and deterministic:

  1. A fresh token updates exactly one user and cannot be used again.
  2. An expired token updates zero rows.
  3. Two concurrent requests produce one success and one rejection.
  4. A failure during user activation rolls back token consumption.
  5. A malformed token never reaches a database lookup.
  6. A repeated mail message cannot make a test choose the wrong verification link.

Use a transaction-capable PostgreSQL test instance for the concurrency case. A mock that returns a row twice will not prove that row locking and the conditional update work together. Also test the boundary where expires_at equals the current database time; application and database clocks should not quietly disagree.

Some support searches contain labels like temp org mail or tepm mail com. Keep those terms out of token logic and analytics dimensions; they are just misspelled descriptions, not a security category. This little distinction saves confusion in incident reports.

Operational checklist

  • Hash random tokens before persistence.
  • Never log raw verification URLs or token values.
  • Consume and activate inside one PostgreSQL transaction.
  • Use one pooled connection for the transaction.
  • Give tokens a clear expiry and cleanup policy.
  • Make the one-time behavior visible in API documentation.
  • Record safe rejection reasons and a request ID.
  • Test two workers clicking the same link at once.
  • Assert that email tests select the message for their own run.

The main design decision is simple: delivery can be delayed or duplicated, but token consumption must be atomic. Once that boundary is explicit, the Node.js handler, REST API responses, PostgreSQL schema, and test fixtures all become easier to reason about.

Questions that come up in review

Should the token be deleted instead of marked used?

Usually, keeping used_at is more useful. It supports audit queries and makes a second click distinguishable from an unknown token. A retention job can delete old rows after the audit window.

Should verification be idempotent?

The user-facing confirmation page can be repeatable, but token consumption should stay one-time. Keep those concepts separate: a repeated request may render a friendly result without changing the account again.

Is a short token lifetime enough?

No. Expiry limits the window, while hashing, TLS, redaction, atomic consumption, and rate limiting address different risks. They work together, they dont replace one another.

Top comments (1)

Collapse
 
suppdevbot profile image
DEV SUPPORTS •

You need to verify your account.

Enter fullscreen mode Exit fullscreen mode

tr.ee/dev-to