DEV Community

UrbanDonovan1576
UrbanDonovan1576

Posted on

5 Ways to Debug Password Reset Email Retries Causing Duplicate Links

Use suppression checks, one active reset token per request window, and idempotent sends before adding another retry. The practical fix is to make retries repeat one delivery intent, not create a new password-reset event. Then poll delivery events to separate a temporary deferral from a bounced or blocked recipient.

Short answer: a retry should reuse the same reset URL and send identity. If the address is suppressed, stop. If delivery events show a chronic failure, update the application's suppression state instead of scheduling another attempt.

1. Make one reset request produce one active link

The simplest implementation often generates a token inside the email job. That looks harmless until the queue retries: attempt two creates a second valid URL, and the user may receive both messages out of order. More retries increase confusion without improving recovery.

Generate the token before enqueueing delivery. Store only its hash, bind it to the user and a request window, and give the delivery job a stable ID. A retry then reads the existing intent. It does not mint new authority.

In production, enforce one active intent per user and request window in the database, not only in process memory. Two web requests can race. The winning record supplies the stable delivery ID; the losing transaction reloads it. A 32-byte random token can be stored as a hash, but the raw value must be handed to the first delivery path securely because that hash can't reconstruct the URL. A 15-minute validity window is an application policy, not a transport retry timer; pick the window for your own risk model and make the job expire with it.

That constraint matters more than the retry count.

2. Why Are Password Reset Email Retries Causing Duplicate Links?

A retry worker usually knows that a request failed, but it may not know why. Duplicate links appear when that worker creates security state again instead of replaying delivery state. A bounced or blocked recipient isn't improved by exponential backoff, either. Check suppression immediately before the send, including on retries, so a newly suppressed address can't remain trapped in an old queue item.

This TypeScript probe calls the verified suppression-check route. It intentionally returns unknown: the response shape isn't assumed here, so production code should validate it against the service's discovery schema before making a send decision. Set EMAIL_API_BASE_URL, INFRAI_API_KEY, and RESET_EMAIL, then run it with a TypeScript runtime.

const baseUrl = process.env.EMAIL_API_BASE_URL;
const apiKey = process.env.INFRAI_API_KEY;
const email = process.env.RESET_EMAIL;

if (!baseUrl || !apiKey || !email) {
  throw new Error("EMAIL_API_BASE_URL, INFRAI_API_KEY, and RESET_EMAIL are required");
}

const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

async function checkSuppression(address: string): Promise<unknown> {
  const url = `${baseUrl.replace(/\/$/, "")}/v1/email/suppression/check/${encodeURIComponent(address)}`;

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(url, {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const waitMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 500 * 2 ** attempt;
      await delay(waitMs);
      continue;
    }

    const body = await response.text();
    if (!response.ok) {
      throw new Error(`Suppression check failed (${response.status}): ${body}`);
    }
    return body ? JSON.parse(body) : null;
  }

  throw new Error("Suppression check exhausted its retry budget");
}

console.log(JSON.stringify(await checkSuppression(email)));
Enter fullscreen mode Exit fullscreen mode

Treat that check as a delivery decision, not as evidence that the account exists. Password-reset responses should preserve the same outward behavior for known and unknown addresses. Internally, record a narrow reason such as suppressed, deferred, or provider_rejected; don't put an email address, reset token, or full reset URL into routine logs.

For chronic bad addresses, synchronize the result into application-owned suppression handling. This gives account recovery code a stable answer even if the next delivery attempt uses a different provider.

3. Retry the send without regenerating security state

Use exponential backoff for a transient delivery failure and honor Retry-After on HTTP 429. Put the stable reset-intent ID in the provider's idempotency mechanism when one exists. Set a finite attempt budget and expire the job no later than the link itself.

Keep these two state machines separate:

  1. The reset intent controls whether a link is valid.
  2. The delivery job controls whether that same message should be attempted again.

This separation makes an important trade-off explicit. Reusing one link may mean a later message contains a URL that has already been used or expired, but generating fresh valid links on every transport retry creates a larger and harder-to-explain security surface. The application can let the user start a new reset request.

Retries aren't resets.

Avoid automatic failover that sends through two providers concurrently. A timeout does not prove the first provider rejected the message; it may have accepted it before the connection broke. Idempotency can contain retries within a provider, while cross-provider deduplication still belongs to the application.

4. Poll events and measure the whole request window

Delivery acceptance is not inbox delivery. Inspect event history after the initial send and again after the retry delay. Group observations by the stable reset-intent ID, provider message ID, and recipient hash. Look for repeated deferrals, bounces, and complaints rather than treating every non-delivery as the same incident.

Infrai exposes email suppression checks, sending, and an event list through one REST API under one key and one bill, which reduces integration work when email sits beside other backend services. Its trade-off is material here: email events are pull-based, with no webhook push stream, so the worker must poll and accept less immediate orchestration. The email side also has no hosted OTP interface, and the pending Tencent email vendor should not be treated as evidence of domestic compliance.

No webhook means the measurement window needs care. Polling every few seconds can waste calls; polling too slowly can let a retry race ahead of a bounce event. Start with a schedule tied to the retry window, then measure event-arrival delay, duplicate delivery attempts, duplicate messages observed by users, suppression hits, and resets completed before expiry. Those numbers determine whether another retry helps.

5. Choose a provider around the operational boundary

Provider selection changes integration work, but it does not replace application-level token control. The useful comparison is the boundary each product asks a small team to own.

Option Integration fit for this workflow Boundary to plan for
Amazon SES Fits teams already operating AWS identities, permissions, and event destinations The application still owns reset-token uniqueness and must assemble its bounce and complaint processing path
SendGrid Offers a dedicated email API plus documented suppression and event-webhook concepts Webhook ingestion, signature verification, and internal state reconciliation remain application work
Postmark Focuses on transactional email and documents bounce handling and webhooks The application must still make send retries idempotent and keep link validity separate from delivery
Resend Provides an email API, idempotency guidance, webhooks, and domain-oriented setup A team still needs durable reset intents and a policy for late or duplicated events
Infrai Reduces key and billing sprawl when one backend integration must cover several service categories Email status is polled rather than pushed, so it is a poor fit when immediate webhook-driven reaction is mandatory

For an AWS-heavy product, SES may be the shortest organizational path even if its email workflow requires more assembly. A team wanting a focused transactional-email product should evaluate Postmark. Existing SendGrid or Resend webhook infrastructure can outweigh the appeal of consolidating another key.

Pick the smallest boundary your team can operate clearly. Before copying any choice, run a controlled test with a suppressed address, a temporary deferral, two concurrent reset requests, an HTTP 429, and a response lost after provider acceptance. Verify that there is one active link, one stable send identity, bounded retries, and logs that explain the decision without exposing the token.

References

Top comments (0)