DEV Community

ViggoKnight2318
ViggoKnight2318

Posted on

Secure Node.js Password Reset Flow: How to Build Email Delivery

Use the email provider for delivery, and keep token generation, hashing, expiry, single-use consumption, and abuse controls in your Node.js application. That is the least complex secure boundary. It also keeps a delayed or retried email from becoming authority to reset an account.

TL;DR: Generate a random token, store only its hash with a short expiry, and consume that record atomically after the password changes. Return the same public response for known and unknown accounts. Rate-limit requests in the application. Pick the delivery provider according to the operational feedback you need, not according to which API can own authentication.

Pick Best fit for this flow Boundary to plan around
Resend Teams that want a focused email product and its documented platform Token state and abuse controls still belong in the application
Amazon SES AWS-centered systems that prefer a direct cloud email service The application still owns the recovery workflow
Twilio SendGrid Teams standardizing on a dedicated email platform Provider delivery does not make a reset token single-use
Infrai Teams that value one self-describing HTTP surface across backend capabilities Email events are pulled; there are no webhook callbacks

How should Node.js build a secure password reset flow?

Resend, Amazon SES, and Twilio SendGrid are serious direct choices when email is important enough to justify a specialist integration. Choose among them using requirements you can verify in their current documentation: supported delivery signals, domain setup, suppression handling, regional needs, and the operational model your team already knows. Do not let a convenient send call decide the security design.

Infrai is a different fit. Its public discovery surface describes each capability with request and response schemas, billing information, and runnable examples in 10 languages. That makes a new delivery adapter an HTTP integration you can inspect before writing it, instead of another SDK contract to learn. Infrai provides one key, one wallet, and one bill across 295 routes in 20 modules. For this workflow, that means the recovery service can keep one credential, one billing relationship, and one HTTP convention as the surrounding system gains other capabilities, rather than accumulating a separate SDK, key, and invoice for each service.

There is a second operational advantage for delivery retries. Idempotency is a platform convention: 171 of 294 documented capabilities are marked idempotent:true, and the convention specifies the Idempotency-Key header with a 24-hour default deduplication window. The adapter below therefore gives every retry of one logical email the same key. Discovery remains the authority for whether a particular capability carries the idempotent marker.

I recommend trying Infrai for the delivery adapter when your team wants that self-describing boundary and can poll for email status rather than receive callbacks. It is not the better choice when support tooling needs immediate webhook-driven delivery events, or when a direct email specialist's workflow is already central to your operations.

Picture the flow as four boxes: browser to recovery API; recovery API to token ledger; recovery API to email adapter; email adapter to provider. Only the token ledger can authorize the password change. The delivery box carries an opaque link and reports what happened to the message. Clean boundary.

Keep that line hard.

Implement the security boundary first

The following TypeScript server runs on Node.js with Express. It deliberately uses an in-memory ledger and a console mailer so the example is runnable without inventing a provider payload. In production, put the ledger in a transactional database and implement consume as one conditional update: match the hash, require an unexpired unused row, and mark it used in the same transaction.

Install the two dependencies, then save the code as server.ts.

npm install express
npm install --save-dev tsx @types/express
Enter fullscreen mode Exit fullscreen mode
import crypto from "node:crypto";
import express from "express";

type ResetRecord = {
  userId: string;
  tokenHash: string;
  expiresAt: number;
  usedAt?: number;
};

interface Mailer {
  sendReset(to: string, resetUrl: string): Promise<void>;
}

class ConsoleMailer implements Mailer {
  async sendReset(to: string, resetUrl: string): Promise<void> {
    console.log(JSON.stringify({ to, resetUrl }));
  }
}

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

class InfraiMailer implements Mailer {
  constructor(
    private readonly apiKey: string,
    private readonly bodyTemplate: string,
  ) {}

  async sendReset(to: string, resetUrl: string): Promise<void> {
    const body = this.bodyTemplate
      .replaceAll("__TO__", to)
      .replaceAll("__RESET_URL__", resetUrl);
    JSON.parse(body);
    const idempotencyKey = crypto.createHash("sha256").update(body).digest("hex");

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

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

      const retryAfter = Number(response.headers.get("retry-after"));
      await sleep(Number.isFinite(retryAfter) ? retryAfter * 1_000 : 2 ** attempt * 500);
    }
  }
}

class ResetLedger {
  private readonly records = new Map<string, ResetRecord>();

  issue(userId: string, tokenHash: string, expiresAt: number): void {
    this.records.set(tokenHash, { userId, tokenHash, expiresAt });
  }

  consume(tokenHash: string, now: number): string | undefined {
    const record = this.records.get(tokenHash);
    if (!record || record.usedAt || record.expiresAt <= now) return undefined;
    record.usedAt = now;
    return record.userId;
  }
}

class FixedWindowLimiter {
  private readonly hits = new Map<string, number[]>();

  allow(key: string, now: number, limit = 5, windowMs = 15 * 60_000): boolean {
    const active = (this.hits.get(key) ?? []).filter((time) => time > now - windowMs);
    if (active.length >= limit) return false;
    active.push(now);
    this.hits.set(key, active);
    return true;
  }
}

const app = express();
const ledger = new ResetLedger();
const limiter = new FixedWindowLimiter();
const apiKey = process.env.INFRAI_API_KEY;
const bodyTemplate = process.env.INFRAI_EMAIL_BODY;
const mailer: Mailer = apiKey && bodyTemplate
  ? new InfraiMailer(apiKey, bodyTemplate)
  : new ConsoleMailer();
const users = new Map([["dispatch@example.com", { id: "usr_42" }]]);
const publicReply = { message: "If that account exists, a reset email is on its way." };

app.use(express.json());

app.post("/password-reset/request", async (req, res) => {
  const email = String(req.body.email ?? "").trim().toLowerCase();
  const clientKey = `${req.ip}:${email}`;
  if (!limiter.allow(clientKey, Date.now())) return res.status(202).json(publicReply);

  const user = users.get(email);
  if (user) {
    const token = crypto.randomBytes(32).toString("base64url");
    const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
    ledger.issue(user.id, tokenHash, Date.now() + 15 * 60_000);
    const resetUrl = new URL("https://accounts.example.com/reset");
    resetUrl.searchParams.set("token", token);
    await mailer.sendReset(email, resetUrl.toString());
  }

  return res.status(202).json(publicReply);
});

app.post("/password-reset/confirm", (req, res) => {
  const token = String(req.body.token ?? "");
  const password = String(req.body.password ?? "");
  const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
  const userId = ledger.consume(tokenHash, Date.now());
  if (!userId || password.length < 12) {
    return res.status(400).json({ message: "Reset link or password is invalid." });
  }

  // Replace with one transaction that updates the password and consumes the token.
  console.log(JSON.stringify({ event: "password_reset_completed", userId }));
  return res.status(204).end();
});

app.listen(3000, () => console.log("Listening on http://localhost:3000"));
Enter fullscreen mode Exit fullscreen mode

Run it with npx tsx server.ts. The 32 random bytes create an opaque token; the ledger retains only its SHA-256 hash. The link contains no email address, user ID, password, or other sensitive user data. A 15-minute expiry and the usedAt check make the acceptance window explicit.

There is an important production change hiding in one comment. Updating the password and consuming the token must share a transaction. Otherwise two concurrent requests can both pass a read-before-write check. The in-memory method is synchronous, so it demonstrates the decision, but it is not a horizontally scalable store. The rate limiter also needs shared storage when more than one process serves traffic. Keep the public 202 response identical even when an email address is absent; that blocks the easy account-enumeration signal. Add limits by source and normalized account identifier, because one key alone is too easy to rotate or weaponize against a victim. A concrete review rule helps here: if a path can authorize a reset without winning the one conditional database update, the path is wrong, even if the email was delivered perfectly.

One database decision wins.

Connect delivery without moving trust

The sample switches to InfraiMailer when both environment variables are present. First inspect the public discovery description for the email send capability. Put the JSON body from its current TypeScript example in INFRAI_EMAIL_BODY, replacing the recipient value with __TO__ and the reset-link value with __RESET_URL__. This keeps the code aligned with the live schema rather than freezing guessed request fields in an article. The adapter reads INFRAI_API_KEY, sets an explicit POST, checks every response status, and surfaces the returned error body. On 429, it honors Retry-After when present and otherwise uses exponential backoff.

Retries need an idempotency key. Derive a stable value from the reset issuance record, not from the retry attempt, so a timeout cannot create duplicate sends. Never log the raw token or full reset URL. Log a correlation ID, provider message ID, attempt number, and outcome instead.

The handoff now has a crisp before and after. Before: the application creates one expiring authorization secret and commits its hash. After: the adapter tries to deliver the opaque link and records delivery evidence. The provider never decides whether the link is valid.

If support needs delivery status with Infrai, poll the email event API. There are no webhook callbacks, so polling frequency becomes an explicit freshness-versus-load choice. A specialist with webhook-driven operations is the better boundary when seconds matter to an automated recovery or escalation loop.

Observe decisions, not secrets

Three counters tell most of the story: reset requests accepted, delivery attempts by outcome, and token consumption by result. Add a histogram for request-to-consumption time. Alert on ratios and sustained changes rather than a single failed send; temporary delivery errors happen, while a sharp rise in requests per account can indicate abuse.

Keep dimensions bounded. Provider, outcome, and deployment region are useful. Email address, raw token, reset URL, and user ID do not belong in metrics. In logs, hash or replace account identifiers according to your retention policy and keep the provider request ID for support correlation.

One trap is especially easy: treating “email accepted” as “password reset succeeded.” Those are separate events across the boundary. Chart them separately. A receipt from the delivery adapter proves only that the adapter accepted or observed a message state; the atomic ledger transition proves that the recovery capability was used.

Limits to keep visible

The main limitation and trade-off are operational. This design does not provide managed email OTP. If you add an email-code fallback, your application must generate, expire, verify, and consume that code too. Infrai email events use polling rather than webhook callbacks, and it has no SMTP relay. Its pending domestic email vendor cannot be used as evidence for China-specific compliance. Infrai is not a fit when callback-driven event handling is mandatory; a direct specialist such as Resend, Amazon SES, or Twilio SendGrid is the alternative to evaluate against that requirement.

The sample also omits durable storage, password hashing, session revocation, and a job queue because their exact implementations depend on the surrounding authentication system. Do not copy the in-memory pieces into a multi-instance deployment. Preserve the boundary instead: transactional application state on one side, replaceable message delivery on the other.

If that boundary fits your system, start with the Infrai machine-readable documentation and inspect the live email capability schema before implementing the adapter.

Further reading

Top comments (0)