DEV Community

EvanShepherd8274
EvanShepherd8274

Posted on

Build a Secure Node.js Password Reset Flow with Express Email Evidence

To build a secure password reset flow in Node.js and Express, treat the email link as a short-lived credential and the bounce record as separate compliance evidence. Treating mail as only a send operation creates gaps: invalid recipients remain eligible, bounce records get mixed with authentication state, and an operator cannot explain what happened to a recovery attempt.

Short answer: keep token creation, hashing, expiry, single-use consumption, and rate limits inside the Node.js and Express application; use email delivery events as separate evidence for bounce suppression and support review.

Design choice Best fit Compliance consequence
Application-owned recovery state Most fintech products The database can prove which token was issued, consumed, or expired
Provider event state kept separately Any product sending recovery mail A bounce can suppress future delivery without becoming a password decision
Queue between the app and mail transport Apps that must retry safely The message attempt can be audited without retaining a usable token
Dedicated email operations Teams needing immediate event callbacks Better event timing, but another control plane to document and operate

For a one-person SaaS, this is a revenue-per-hour decision. Outsource the undifferentiated transport and spend scarce engineering time on the invariant that protects accounts. Ship weekly, but make the audit trail boring.

Evidence first.

Keep it separate.

What does a fintech password reset flow need to prove about email evidence?

Start with two records, not one. The recovery record contains a digest of a random token, its creation time, expiry, user reference, and consumption state. A delivery record contains the message attempt, recipient classification, provider reference if available, and delivery outcome. The second record may say “accepted,” “delivered,” or “bounced”; it must never decide whether a token is valid.

That separation answers different questions. Authentication asks, “Can this presented secret consume an eligible recovery record?” Compliance and support ask, “What did the system send, to which normalized recipient, and what event followed?” A bounce can add the address to a suppression set. It cannot retroactively make an already-issued token valid or invalid. I use that distinction as the first review question because it catches an entire class of accidental coupling before it reaches production.

The public request response should be identical for an existing account, an unknown account, and a throttled request. Otherwise the endpoint leaks account existence. Rate-limit both a network key and a normalized-recipient key. The thresholds belong to the product's traffic and abuse model; I'm not sure a universal number exists, and a copied limit can block legitimate finance users during a mailbox recovery loop.

Use SPF and the rest of the domain-authentication policy as delivery evidence, not as proof of account ownership. RFC 7208 describes SPF's role in checking whether a sending host is authorized for a domain. That is useful for mail posture. It does not turn a delivered message into a successful password change.

How do Node.js Express email links use hashed tokens, expiry, single use, and rate limits?

Generate the raw token with a cryptographic random source. Put only the raw value in the link. Store a SHA-256 digest for lookup because the token is high entropy; this is separate from the password hash. Keep the raw value out of application logs, traces, analytics URLs, and delivery records.

The dangerous part is consumption. A handler that validates a token, changes the password, and deletes the record in three unrelated calls can allow two concurrent requests through. The store needs one conditional transaction: match the digest, require an unused and unexpired record, update the password, and mark the recovery record consumed. Exactly one caller should win.

That race is easy to miss in a happy-path test. Send two consume requests with the same token at the same time. If both receive success, the API has demonstrated a security failure even though every individual query looked reasonable. The fix is a database transaction with a conditional update, not a second check in Express.

No shortcut.

Here is the application boundary. The delivery adapter receives a reset URL, but it cannot inspect or consume recovery state. The store owns the security decision.

import crypto from "node:crypto";
import express from "express";

type RecoveryStore = {
  issue(input: {
    email: string;
    tokenHash: string;
    expiresAt: Date;
  }): Promise<{ userId: string; email: string } | null>;
  consumeAtomically(input: {
    tokenHash: string;
    newPasswordHash: string;
    now: Date;
  }): Promise<boolean>;
};

type DeliveryQueue = {
  enqueue(input: {
    to: string;
    resetUrl: string;
    auditId: string;
  }): Promise<void>;
};

type RateLimiter = { allow(key: string): Promise<boolean> };
type PasswordHasher = { hash(value: string): Promise<string> };

type Dependencies = {
  store: RecoveryStore;
  delivery: DeliveryQueue;
  limiter: RateLimiter;
  passwords: PasswordHasher;
  appOrigin: string;
};

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

const normalizeEmail = (value: string) => value.trim().toLowerCase();

export function passwordRecoveryRouter(deps: Dependencies) {
  const router = express.Router();
  router.use(express.json());

  router.post("/password-reset/request", async (request, response, next) => {
    try {
      const email = normalizeEmail(String(request.body.email ?? ""));
      const ipKey = hashToken(`ip:${request.ip}`);
      const recipientKey = hashToken(`email:${email}`);
      const [ipAllowed, recipientAllowed] = await Promise.all([
        deps.limiter.allow(ipKey),
        deps.limiter.allow(recipientKey),
      ]);
      const publicResponse = {
        message: "If the account exists, a reset link will be sent.",
      };

      if (!ipAllowed || !recipientAllowed) {
        response.status(202).json(publicResponse);
        return;
      }

      const rawToken = crypto.randomBytes(32).toString("base64url");
      const account = await deps.store.issue({
        email,
        tokenHash: hashToken(rawToken),
        expiresAt: new Date(Date.now() + 15 * 60 * 1000),
      });

      if (account) {
        const resetUrl = new URL("/reset-password", deps.appOrigin);
        resetUrl.searchParams.set("token", rawToken);
        await deps.delivery.enqueue({
          to: account.email,
          resetUrl: resetUrl.toString(),
          auditId: crypto.randomUUID(),
        });
      }

      response.status(202).json(publicResponse);
    } catch (error) {
      next(error);
    }
  });

  router.post("/password-reset/consume", async (request, response, next) => {
    try {
      const token = String(request.body.token ?? "");
      const password = String(request.body.newPassword ?? "");
      const changed = await deps.store.consumeAtomically({
        tokenHash: hashToken(token),
        newPasswordHash: await deps.passwords.hash(password),
        now: new Date(),
      });

      if (!changed) {
        response.status(400).json({
          message: "This reset link is invalid or expired.",
        });
        return;
      }

      response.status(204).end();
    } catch (error) {
      next(error);
    }
  });

  return router;
}
Enter fullscreen mode Exit fullscreen mode

The 15-minute window is an example policy. The important fact is where the check happens: consumeAtomically compares expiry using database-owned state at the time of the transaction. Test two simultaneous consume requests for one digest and assert that one succeeds. Also test an expired digest, an unknown digest, repeated requests from one address, and a bounce followed by a new request. A 202 response from the request endpoint is deliberately uninformative; it prevents an account lookup from being disguised as a mail feature.

Which bounce and delivery signals belong in the audit trail?

A queue makes the state transition explicit. The request handler records that a message was enqueued. A worker records the transport response. A reconciliation job can record later delivery events. None of those stages should store the raw reset token.

The suppression decision should be conservative. A hard bounce for an invalid recipient can suppress future recovery delivery to that address and open a support path. A transient failure should be retried according to the transport's documented policy, with a bounded schedule. Do not treat every negative event as a permanent invalid-recipient finding; that creates a second account-recovery failure.

The audit record needs enough context to explain the decision later: normalized recipient identifier, account reference, event type, event time, source event ID, and the policy version used. Keep the payload small. In a fintech system, retaining every message body is usually harder to justify than retaining the event needed to demonstrate suppression behavior. When a reviewer asks why an address stopped receiving recovery mail, the useful answer is a chain of IDs and policy decisions, not a copied credential-bearing URL, and that means the event schema deserves the same review as the reset table.

The email vendor's documentation is a source for adapter semantics, not for the application's security policy. For example, Resend describes its email API and onboarding in its public documentation. Your adapter can map its response into the generic delivery record while the recovery service continues to depend on DeliveryQueue only.

When is this architecture the wrong fit?

The catch is operational ownership. A queue, event table, suppression policy, and reconciliation worker are extra moving parts. They are not suitable when the application has no compliance requirement, sends no account-recovery mail at meaningful volume, and can accept a manual support process. A simpler managed workflow may be the right choice there.

The same design is a poor fit if the product requires a provider-specific feature that the generic adapter cannot represent, such as a particular event callback contract or an existing SMTP control plane. Keep the incumbent when its evidence, access controls, and incident process are already accepted by the compliance team. A migration that loses audit clarity is not an improvement.

For the fintech scenario, the decision rule is narrower: choose the least complex transport that can produce trustworthy delivery evidence, then keep token authority in the application database. Verify the current event semantics and retention terms before production approval. Your mileage may vary because mailbox providers, regional policy, and internal retention rules change the practical answer.

The implementation order is straightforward. Define the recovery and delivery records. Add atomic consumption and neutral responses. Add tests for races and suppression transitions. Then connect the transport adapter and monitor event lag. This keeps the security boundary legible while the delivery layer remains replaceable.

References

Further reading

Top comments (0)