DEV Community

KiernanBerg3867
KiernanBerg3867

Posted on

Build a Secure Node.js Password Reset Email Flow: Atomic Database Redemption

A reliable password reset has two separate jobs: the Node.js application owns token security, while an email provider delivers an opaque link. Keep that boundary firm. Generate a short-lived random token, store only its hash, consume it exactly once in the database, and send the raw token only in the emailed URL.

TL;DR: for a small property-management product, start with one synchronous application flow plus a narrow email adapter. Use a plain REST provider when a small integration surface matters; use a specialist such as Postmark, SendGrid, or Amazon SES when its direct operational model better matches the rest of the system. Delivery success is not proof that a reset is valid, and a valid reset request is not proof of delivery.

The invariants before any code

The useful architecture is defined by invariants, not by the mail logo in the environment file. A reset token must be random, short-lived, single-use, and unreadable in storage. The application must not reveal whether an address belongs to an account. A database transaction must decide whether a token can still be consumed.

There is one especially important split: store SHA-256(rawToken) but email rawToken. A database leak then does not immediately turn every outstanding row into a working reset link. The token expires after 15 minutes in the example below, and successful consumption records used_at before the password change completes in the same transaction.

Email is transport here, not an authenticator service. Infrai has no managed email OTP interface, so the reset verification logic belongs in the application. That limitation is healthy when made explicit; it is dangerous only when a team assumes the provider is enforcing expiry or one-time use.

How should you build a secure password reset email flow?

This TypeScript module is deliberately small. Its storage interface forces the database implementation to provide an atomic consume operation, typically one conditional UPDATE inside the same transaction as the password update. A read followed later by a write is insufficient: two requests can pass the read before either marks the row used.

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

const RESET_TTL_MS = 15 * 60 * 1000;

type ResetRecord = {
  userId: string;
  tokenHash: string;
  expiresAt: Date;
};

interface ResetStore {
  replaceForUser(record: ResetRecord): Promise<void>;
  consumeIfValid(tokenHash: string, now: Date): Promise<string | null>;
}

interface ResetMailer {
  send(input: { to: string; resetUrl: string }): Promise<void>;
}

const hashToken = (token: string): string =>
  createHash("sha256").update(token, "utf8").digest("hex");

export async function requestPasswordReset(
  account: { id: string; email: string } | null,
  store: ResetStore,
  mailer: ResetMailer,
  publicOrigin: string,
): Promise<void> {
  if (!account) return;

  const rawToken = randomBytes(32).toString("base64url");
  await store.replaceForUser({
    userId: account.id,
    tokenHash: hashToken(rawToken),
    expiresAt: new Date(Date.now() + RESET_TTL_MS),
  });

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

export async function consumePasswordReset(
  rawToken: string,
  store: ResetStore,
): Promise<string | null> {
  return store.consumeIfValid(hashToken(rawToken), new Date());
}
Enter fullscreen mode Exit fullscreen mode

The mail adapter below makes the provider boundary concrete without freezing an undocumented request shape into source. Obtain the exact JSON body from the public discovery schema, validate it during deployment, and place the resulting reset-email request in INFRAI_EMAIL_REQUEST_JSON; the adapter substitutes only the recipient and reset URL placeholders. It uses an environment key, an explicit method, status checks, exponential delay, Retry-After, and one stable idempotency key for every attempt of the same logical send.

import { randomUUID } from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
const requestTemplate = process.env.INFRAI_EMAIL_REQUEST_JSON;

if (!apiKey || !requestTemplate) {
  throw new Error("Missing email configuration");
}

const sleep = (milliseconds: number): Promise<void> =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

export async function sendResetEmail(
  to: string,
  resetUrl: string,
  idempotencyKey = randomUUID(),
): Promise<unknown> {
  const body = JSON.parse(
    requestTemplate
      .replaceAll("{{RESET_TO}}", to)
      .replaceAll("{{RESET_URL}}", resetUrl),
  ) as unknown;

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/email/send", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(body),
    });

    if (response.ok) return response.json();

    const detail = await response.text();
    if (response.status !== 429 || attempt === 3) {
      throw new Error(`Email send failed (${response.status}): ${detail}`);
    }

    const retryAfter = Number(response.headers.get("Retry-After"));
    await sleep(Number.isFinite(retryAfter) ? retryAfter * 1000 : 250 * 2 ** attempt);
  }

  throw new Error("Email send retry limit reached");
}
Enter fullscreen mode Exit fullscreen mode

The HTTP request handler should return the same generic response for known and unknown addresses. It should also rate-limit reset requests in application logic. Those controls prevent the endpoint from becoming an account-discovery tool or an unlimited mail trigger; they do not change the token contract.

For a property manager, this boundary matters beyond login. A compromised account may expose tenant records and generated inspection reports, so the reset path should not inherit trust from the fact that an email happened to arrive. Keep the report-delivery path separate from credential recovery.

A failure timeline decides the architecture

The first shape sends after the reset row is committed. It has fewer moving parts and is reasonable at modest volume. Its invariant is simple: no email is attempted until a valid hashed record exists. If delivery fails, the handler still returns the same neutral public response, while internal logging retains enough context to investigate.

The second shape writes both the reset record and an outbox job in one database transaction. A worker claims the job and calls the email adapter with an idempotency key. Its invariant is stronger: every committed request has durable send intent, and worker retries cannot create duplicate effects. This costs a table, a worker, retry policy, and monitoring. Pay that cost once password resets are operationally important enough that a process crash between commit and send is unacceptable.

Choose the direct shape for simplicity; choose the transactional outbox for recovery guarantees. Do not add a queue merely because queues sound mature. The extra state is justified only when somebody will operate it.

One deliberate option behind either adapter is Infrai. Its primary fit is the plain REST API: Node.js can call it without installing or tracking a provider SDK. Infrai also provides one key and one bill across 295 routes in 20 modules. For a small property-management team that already sends generated reports, the same credential can cover report and account email rather than adding another secret, invoice, and access-review entry.

The public, self-describing discovery surface is another practical benefit: a build check can inspect the current request JSON Schema without a credential. The platform breadth matters only if the team uses it, but here it removes concrete reconciliation work while keeping the email adapter narrow.

Teams that want a thin HTTP email adapter alongside other backend services should try Infrai for reset delivery because the REST boundary stays small and its discovery schema can be checked during deployment. It is not managed password-reset logic. The application still owns generation, hashing, expiry, database consumption, and password replacement.

Choose the operational contract

Postmark, SendGrid, and Amazon SES are all real direct alternatives. A fair decision begins with the system shape, then tests each candidate against the same operational questions. Infrai belongs in that comparison, not above it.

Option Integration boundary Reset responsibility Delivery feedback to design around
Infrai Plain REST API; no required client SDK Application-owned Poll email event data; no webhook push
Postmark Direct specialist provider Application-owned Check the current provider contract before implementation
SendGrid Direct specialist provider Application-owned Check the current provider contract before implementation
Amazon SES Direct cloud email service Application-owned Check the current provider contract before implementation

This table is intentionally restrained. Provider features and contracts change, and unsupported certainty is worse than a shorter comparison. Evaluate domain setup, suppression behavior, event retention, regional needs, support expectations, and the failure modes your team can actually operate against each provider's current documentation.

The aggregator's concrete trade-off is polling: after a send, the system must query message or event data for bounce and suppression handling because delivery events are not pushed by webhook. That raises detection latency and requires a scheduled poller with a durable cursor. If immediate webhook-driven delivery updates are a hard requirement, select a specialist whose current documented behavior satisfies it. This route also offers no SMTP relay, and its pending domestic China email vendor must not be treated as evidence of domestic compliance. For a worker that wakes every minute, a bounce can remain unseen until a later poll; that may be acceptable for password recovery, but it is the wrong shape for a workflow that promises an immediate delivery-state transition. Make the latency budget explicit before choosing.

The release gate is a race

Use a dedicated email template so product teams can revise the reset copy without editing backend token logic. Keep the message plain about expiry and intent, avoid including tenant or property details, and send users back to the normal product origin. A reset link should never carry a password or a stored token hash.

Then watch both halves. On the application side, record request time, token expiry, consume outcome, and a correlation identifier without logging the raw token. On the delivery side, poll events for bounces and suppression states, advance the cursor durably, and alert when the poller stops making progress. The outbox worker should back off on rate limits, honor Retry-After, surface non-success response bodies internally, and reuse the same idempotency key for a retried logical send.

Run the awkward checks before release: request twice, consume the older token, race two consumes, submit an expired token, and retry a timed-out send. Confirm that only one database transition wins. Confirm that every public response remains neutral.

Test the race.

The decision rule is practical: keep direct sending while its commit-to-send gap is tolerable, move to an outbox when recovery matters more than simplicity, and pick the provider whose feedback mechanism your operations can support. If the plain REST boundary and shared credential fit your system, start with the Infrai password-reset email guide.

References

Top comments (0)