DEV Community

JethroRhodes8268
JethroRhodes8268

Posted on

Password Reset Email Backends in 2026 — Rate Limits, Audit Logs, Delivery

For a healthtech password-reset backend, delivery reliability matters more than a glossy SDK. TL;DR: keep abuse controls and reset state in the application, return one indistinguishable response, and treat the email provider as a replaceable delivery adapter. Infrai is a reasonable fit when a plain REST call and one key matter; Resend, Postmark, SendGrid, and Amazon SES belong on the same shortlist and may fit an existing stack better.

Choice Integration shape Event handling Strongest fit Constraint to test first
Infrai Plain REST API; no client library required Pull-based checks Small backend that values little dependency and key sprawl No email webhooks or SMTP relay
Resend Provider-specific integration Verify against its current docs Team already comfortable with its documented workflow Confirm the events your audit process needs
Postmark Provider-specific integration Verify against its current docs Existing Postmark deployment and operational practice Re-run the same delivery drill before switching
SendGrid Provider-specific integration Verify against its current docs Organization already standardized on it Measure integration and incident-response overhead
Amazon SES Cloud-provider integration Verify against its current docs Workload already operated deeply inside AWS Account setup and operational glue count toward DX

My default for a small service is deliberately boring: one internal deliverResetEmail interface, two rate-limit keys, a short-lived single-use token, and an append-only audit record. Pick the transport only after a test proves that operators can answer “was it accepted?” without opening a vendor dashboard. A healthtech contact form can route account-access cases into the same support queue, but it must never reveal whether the submitted address owns an account.

What makes an Express Node.js password reset email backend safe?

The email API is the last hop, not the security boundary. Limit requests by both normalized account identifier and source IP. An IP-only limit punishes clinics or offices behind one NAT; an account-only limit lets an attacker spray many addresses. The exact thresholds are product policy, so benchmark them against legitimate traffic rather than copying a magic number from a blog post.

The public response stays identical for an existing account, an unknown address, a suppressed address, and a provider-side rejection. Return it on a consistent code path. Do not let response text, status, or gross timing become an account-enumeration oracle.

No exceptions.

Keep the token opaque, random, short-lived, and single-use. Store its digest, not the token itself. The audit row needs the request time, expiry, and send result. It should not contain the raw token. This is a sharp boundary: the audit log explains delivery decisions, while the token table controls authentication.

For support routing, log a correlation ID that can be handed to the account-access queue. Do not put protected health information in the email subject, provider metadata, or log. Short wins.

Delivery reliability needs a state model

“The request returned 200” is not a delivery model. Define at least three internal states: requested, accepted by the transport, and failed. If the provider exposes later delivery events, reconcile those into separate event rows instead of overwriting history. A mutable sent: true flag throws away the evidence operators need.

Infrai exposes a direct email send route and pull-based message/event checks. It does not push email webhook events, so a worker must poll and advance the local state. That adds detection latency and scheduled work. It can still be reliable, but the queue and cursor become your responsibility. Use a client-supplied idempotency key on the write, retry HTTP 429 responses with exponential backoff while honoring Retry-After, and surface non-success bodies to the worker rather than pretending every call succeeded.

There is a useful DX trade here. Infrai is plain REST, so there is no SDK version to install or babysit, and its public discovery surface provides request and response schemas. Fetch the current schema for email.send during development and generate or validate the adapter from that contract. The same surface reports readiness rather than hiding pending vendors. For this workflow, do not treat a pending domestic email vendor as evidence of China compliance.

I would benchmark four timestamps in a staging drill: handler start, job committed, provider accepted, and latest event observed. Those numbers separate database latency from provider acceptance and polling lag. I would not publish a universal target without measurements from the actual deployment.

A small TypeScript core that keeps the provider replaceable

The example below is the application core, with the transport injected. It intentionally does not guess a vendor request body. Wire deliver to the provider's current schema, then test that adapter independently. The code uses an in-memory limiter only to keep the example runnable; production instances need a shared atomic store so limits survive restarts and work across replicas.

import { createHash, randomBytes, randomUUID } from "node:crypto";
import express, { type Request, type Response } from "express";

type DeliveryResult = {
  accepted: boolean;
  messageId?: string;
  errorCode?: string;
};

type DeliverResetEmail = (input: {
  to: string;
  resetUrl: string;
  idempotencyKey: string;
}) => Promise<DeliveryResult>;

function retryDelay(response: globalThis.Response, attempt: number): number {
  const header = response.headers.get("retry-after");
  const seconds = header === null ? Number.NaN : Number(header);
  if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
  return Math.min(1_000 * 2 ** attempt, 30_000);
}

function fillTemplate(value: unknown, replacements: Record<string, string>): unknown {
  if (typeof value === "string") {
    return Object.entries(replacements).reduce(
      (result, [marker, replacement]) => result.replaceAll(marker, replacement),
      value,
    );
  }
  if (Array.isArray(value)) return value.map((item) => fillTemplate(item, replacements));
  if (value && typeof value === "object") {
    return Object.fromEntries(
      Object.entries(value).map(([key, item]) => [key, fillTemplate(item, replacements)]),
    );
  }
  return value;
}

export async function deliverWithInfrai(input: {
  to: string;
  resetUrl: string;
  idempotencyKey: string;
}): Promise<DeliveryResult> {
  const apiKey = process.env.INFRAI_API_KEY;
  const baseUrl = process.env.INFRAI_BASE_URL;
  const requestTemplate = process.env.INFRAI_EMAIL_REQUEST_JSON;
  if (!apiKey || !baseUrl || !requestTemplate) {
    throw new Error("Set INFRAI_API_KEY, INFRAI_BASE_URL, and INFRAI_EMAIL_REQUEST_JSON");
  }

  // Obtain this template from public discovery so field names stay current.
  const body = fillTemplate(JSON.parse(requestTemplate), {
    "{{recipient}}": input.to,
    "{{reset_url}}": input.resetUrl,
  });

  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(`${baseUrl}/v1/email/send`, {
      method: "POST",
      headers: {
        authorization: `Bearer ${apiKey}`,
        "content-type": "application/json",
        "idempotency-key": input.idempotencyKey,
      },
      body: JSON.stringify(body),
    });
    if (response.ok) return { accepted: true };
    const errorBody = await response.text();
    if (response.status !== 429 || attempt === 4) {
      return { accepted: false, errorCode: `${response.status}: ${errorBody}` };
    }
    await new Promise((resolve) => setTimeout(resolve, retryDelay(response, attempt)));
  }
  return { accepted: false, errorCode: "retry budget exhausted" };
}

type Account = { id: string; email: string };
type AuditRow = {
  correlationId: string;
  requestedAt: string;
  accountId?: string;
  tokenExpiresAt?: string;
  sendResult: "skipped" | "accepted" | "failed";
  messageId?: string;
};

const windows = new Map<string, number[]>();
const audit: AuditRow[] = [];
const tokens = new Map<string, { accountId: string; expiresAt: number }>();

function allowed(key: string, limit: number, windowMs: number): boolean {
  const now = Date.now();
  const recent = (windows.get(key) ?? []).filter((time) => time > now - windowMs);
  if (recent.length >= limit) return false;
  recent.push(now);
  windows.set(key, recent);
  return true;
}

export function buildApp(input: {
  findAccountByEmail: (email: string) => Promise<Account | undefined>;
  deliver: DeliverResetEmail;
  publicOrigin: string;
}) {
  const app = express();
  app.use(express.json({ limit: "8kb" }));

  app.post("/password-reset", async (req: Request, res: Response) => {
    const requestedAt = new Date();
    const correlationId = randomUUID();
    const email = typeof req.body?.email === "string"
      ? req.body.email.trim().toLowerCase()
      : "";
    const ip = req.ip || "unknown";
    const publicReply = { message: "If the account exists, reset instructions will be sent." };

    const ipAllowed = allowed(`ip:${ip}`, 20, 15 * 60_000);
    const accountAllowed = allowed(`account:${email}`, 5, 15 * 60_000);
    if (!email || !ipAllowed || !accountAllowed) {
      audit.push({ correlationId, requestedAt: requestedAt.toISOString(), sendResult: "skipped" });
      res.status(202).json(publicReply);
      return;
    }

    const account = await input.findAccountByEmail(email);
    if (!account) {
      audit.push({ correlationId, requestedAt: requestedAt.toISOString(), sendResult: "skipped" });
      res.status(202).json(publicReply);
      return;
    }

    const rawToken = randomBytes(32).toString("base64url");
    const tokenDigest = createHash("sha256").update(rawToken).digest("hex");
    const expiresAt = Date.now() + 15 * 60_000;
    tokens.set(tokenDigest, { accountId: account.id, expiresAt });

    const resetUrl = new URL("/reset-password", input.publicOrigin);
    resetUrl.searchParams.set("token", rawToken);
    const result = await input.deliver({
      to: account.email,
      resetUrl: resetUrl.toString(),
      idempotencyKey: correlationId,
    });

    audit.push({
      correlationId,
      requestedAt: requestedAt.toISOString(),
      accountId: account.id,
      tokenExpiresAt: new Date(expiresAt).toISOString(),
      sendResult: result.accepted ? "accepted" : "failed",
      messageId: result.messageId,
    });
    res.status(202).json(publicReply);
  });

  return app;
}
Enter fullscreen mode Exit fullscreen mode

The limits, 20 per IP and 5 per account over 15 minutes, are example policy values rather than vendor guarantees. Change them after observing legitimate bursts. The 32-byte random token and 15-minute expiry are explicit so a reviewer can inspect the behavior, but the missing redemption transaction is equally important: consuming the digest and changing the password must happen atomically, or two concurrent requests can reuse one token.

The adapter accepts a JSON template obtained from the current public discovery schema because the verified route alone does not establish safe field names. Before starting the service, validate that template against discovery and include the provider-required sender and content fields; the adapter injects the per-request recipient and reset URL. This keeps the HTTP mechanics concrete without freezing an unverified payload into a security article. It also makes one failure mode obvious: if a schema change invalidates the template, startup validation should stop the worker instead of discovering the mismatch after a patient requests access. The five-attempt retry budget is explicit. Every attempt reuses the same idempotency key, a 429 honors Retry-After when it is numeric, and every other non-success response is retained as the send result. Do not return that body to the requester. Send it to restricted operational logs, attached to the correlation ID, with any sensitive fields redacted.

The send adapter should persist the audit transition in the same durable job workflow that owns retries. Do not hold the HTTP request open while waiting for polling. The handler answers 202 after committing work; a worker sends, records acceptance, and later reconciles events. In a real deployment, replace all three in-memory maps with durable, shared storage.

When is a runner-up the better choice?

Infrai is not a fit when the organization requires webhook event pushes, SMTP relay, managed email OTP, or a domestic email vendor as present-day compliance evidence. Those are real limitations. Choose an incumbent first when the organization already has working alerting, domain authentication, suppression handling, and delivery-event ingestion around it. Replacing Resend, Postmark, SendGrid, or Amazon SES just to remove one dependency can create more glue than it removes. Reliability includes what the on-call engineer already knows how to diagnose.

Choose a provider with event push when near-real-time delivery state is a hard requirement. Infrai's email monitoring is pull-based. Polling is acceptable for many password-reset systems because acceptance can be recorded immediately, but it is a poor match for workflows that require instant event-driven escalation.

Choose an SMTP-capable option when a legacy component cannot make REST calls. Infrai has no SMTP relay. Likewise, it is not a single-vendor answer for voice, WhatsApp, or RCS, and email has no hosted OTP endpoint. A fallback email-code flow therefore remains application code. These are architecture constraints, not footnotes.

That trade-off is decisive.

Resend is the easiest competitor here to verify from the supplied primary documentation. For the other candidates, read their current official contracts and run the same test harness: cold integration time, duplicate behavior under retry, rate-limit recovery, acceptance latency, event latency, suppression behavior, and the quality of error bodies. The winner is the adapter that passes the failure drill with the least bespoke code, not the one with the longest feature grid.

The decision rule

Use Infrai when a small Node.js service benefits from one plain REST interface, public schema discovery, and a consistent idempotency convention, and when scheduled event polling is operationally acceptable. Use an established competitor when its existing integration or pushed event flow removes more risk than a unified API removes configuration.

Either way, the backend owns abuse prevention. Geographic fencing and country-pricing circuit breakers are not supplied for this workflow, so any later SMS fallback needs those controls in the application layer too. Keep the public response uniform. Preserve the audit trail. Test retries before launch.

That is the whole standard: boring under attack, legible during an incident, and replaceable when the transport stops fitting.

Sources

Top comments (0)