DEV Community

kevindev
kevindev

Posted on

Node.js Auth Requests Need an Idempotency Ledger

An email-verification endpoint looks simple until a client times out after the server has accepted the request. The browser retries, the mobile app retries again, and suddenly one user has three valid links in an inbox.

The request are not the email. They are a durable command to create or reuse a delivery attempt. Treating that command as state in PostgreSQL makes a Node.js REST API easier to retry, inspect, and operate. It also gives you a clean boundary between authentication state and the provider that eventually delivers a message.

Why sending verification email is a state transition

Consider POST /verification-emails. A client may send the same logical request more than once because of a timeout, a lost response, or a user tapping twice. If every HTTP request creates a fresh token, the service has made the client responsible for deduplication. That is a poor contract.

The endpoint should accept an idempotency key and record a small state machine:

  • accepted: the request was recorded, but no delivery has been claimed
  • queued: a worker can deliver the message
  • sent: the provider accepted the message
  • failed: delivery stopped after a retry policy
  • superseded: a newer verification request invalidated this one

The token itself can have a separate lifecycle. Keeping the request and token concepts distinct is useful: one request may produce one token, while multiple delivery attempts may be made for that token. That distinction pay off when support needs to answer whether a message was never queued, failed at the provider, or was delivered but never used.

For test environments, an isolated disposable email inbox can help validate the delivery edge without touching a real personal mailbox. The inbox is a test dependency, not the source of truth for whether a request was accepted.

Define the request ledger in PostgreSQL

The database needs one unique constraint that represents the client-visible operation. A compact table could look like this:

CREATE TABLE verification_requests (
  id uuid PRIMARY KEY,
  user_id uuid NOT NULL,
  idempotency_key text NOT NULL,
  token_id uuid NOT NULL,
  status text NOT NULL DEFAULT 'accepted',
  attempts integer NOT NULL DEFAULT 0,
  last_error text,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (user_id, idempotency_key)
);

CREATE INDEX verification_requests_queue_idx
  ON verification_requests (created_at)
  WHERE status = 'accepted';
Enter fullscreen mode Exit fullscreen mode

The unique key should be scoped to the user or account. A global key can accidentally make two unrelated users share a result if a poorly generated client key is reused. Store a hash of a sensitive key when the key itself could reveal information; the important property is stable equality, not the original text.

When the same user sends the same key again, return the existing request. Do not silently create a new token. If the payload differs, return a conflict such as 409 Conflict; otherwise a client could reuse a key while changing the destination or verification purpose.

Make retries idempotent in a Node.js REST API

The handler should validate the request, begin a transaction, insert the ledger row, and commit before asking a worker to deliver anything. In pseudocode, the shape is straightforward:

const result = await db.tx(async (tx) => {
  const existing = await tx.oneOrNone(
    `SELECT id, status, token_id
       FROM verification_requests
      WHERE user_id = $1 AND idempotency_key = $2`,
    [userId, idempotencyKey]
  );

  if (existing) return existing;

  const token = await createVerificationToken(tx, userId);
  return tx.one(
    `INSERT INTO verification_requests
       (id, user_id, idempotency_key, token_id)
     VALUES ($1, $2, $3, $4)
     RETURNING id, status, token_id`,
    [requestId, userId, idempotencyKey, token.id]
  );
});

await queue.publish({ requestId: result.id });
return res.status(202).json({ requestId: result.id, status: result.status });
Enter fullscreen mode Exit fullscreen mode

In production, the insert and queue publish need an outbox or another durable handoff. Otherwise the process can commit the database row and crash before publishing the job. This is where many implementations gets their "accepted" response wrong: accepted must mean the system has a recoverable path to delivery, not merely that an in-memory function returned.

An outbox row in the same transaction lets a worker claim work with FOR UPDATE SKIP LOCKED. The worker increments attempts, records provider results, and uses a bounded retry schedule. It should be safe for the same job to be claimed twice, because the provider call and the local state update can still race.

For operational consistency, the response should be boring and clear, which is a feature for maintaners. A repeat request can return 200 with the existing status, while a newly accepted request returns 202. Pick a policy and document it; clients should not have to infer whether a timeout created a second token.

Separate database success from email delivery

Do not mark a verification token as consumed when the email is sent. Sending proves only that a provider accepted a message. Consumption happens when the user presents the token and the API atomically changes it from issued to consumed.

The delivery worker should keep enough evidence to answer three questions:

  1. Was the request accepted and committed?
  2. Was a delivery job claimed and attempted?
  3. Did the provider return an accepted, rejected, or unknown result?

Those facts are more useful than a single email_sent = true flag. They also make alerting possible: a growing accepted backlog points to queue health, while repeated provider rejections point to a different boundary.

Test duplicate and delayed requests

A good test suite should also check the unhappy pathes. At minimum, cover these cases:

  • two concurrent requests with the same user and idempotency key create one token
  • the same key with a changed payload returns a conflict
  • a committed row survives a worker restart before delivery
  • a second delivery attempt does not create a second verification token
  • a consumed token cannot be replayed after another request arrives
  • an expired request is visible to cleanup and support queries

Include realistic malformed input in the test fixtures, such as temp gamil com or tem email, but keep those strings out of production validation rules unless they represent an explicitly supported test case. The spelling is not the interesting part; the boundary between user input, token state, and delivery work is.

If browser tests poll for the message, use a clear stop rule and fresh evidence. This complements less guesswork in Playwright email waits: the test should know which request ID and token it is waiting for, rather than accepting the first message it sees.

A short implementation checklist

  • Require an idempotency key for the verification command.
  • Enforce uniqueness per user and key in PostgreSQL.
  • Return an existing result for a safe retry.
  • Reject a reused key whose payload differs.
  • Commit an outbox event with the request state.
  • Keep delivery attempts separate from token consumption.
  • Add metrics for accepted, queued, sent, failed, and superseded states.
  • Retain enough history to investigate delayed or duplicate requests.

The pattern is intentionally small. It does not remove every email provider failure, but it makes each failure observable and each retry predictable. That is the real goal of an idempotency ledger.

Questions engineers usually ask

Should every verification request get a new token?

No. A new logical request can get a new token, but a retry of the same idempotency key should return the original request. A separate endpoint or an explicit new key can represent "send again."

How long should the ledger be retained?

Keep terminal rows for the period needed for support, security review, and metrics. Then archive or delete them with a separate retention job. Do not let cleanup remove the evidence needed to explain a delivery incident.

Is an idempotency key enough without an outbox?

No. The key prevents duplicate logical requests, but it does not bridge a successful database commit and a failed queue publish. Use a transactional outbox or a queue with a commit-aware integration.

What if the provider result is unknown?

Record unknown and retry according to the provider's guidance. Do not claim certainty from a network timeout. A small amount of ambiguity in the ledger is safer than telling a user that a message definitely was not delivered.

Top comments (0)