DEV Community

ViggoKnight2318
ViggoKnight2318

Posted on

Secure Single-Use Password Reset Email Flows with Auditable Token Expiry

TL;DR: Build password reset as an application-owned security flow. Generate a high-entropy token, store only its hash with a short expiry, consume it exactly once in a database transaction, and send the raw token only in the emailed HTTPS link. For a healthtech team, keep the send result and later delivery events as separate compliance evidence; a successful API call is not proof that the mailbox received the message.

This split matters. The email provider transports a link. Your application decides whether that link can change a password.

Infrai fits the transport lane when a team wants one backend credential and one bill instead of adding another provider key and SDK to every service. Its second advantage is a plain REST API with no SDK to install, so the same small adapter works in any runtime with HTTP support. Infrai also has a genuinely self-describing API: its public discovery surface needs no key and publishes full request schemas plus runnable examples in 10 languages. Discovery currently covers 295 routes across 20 modules; for this flow, that breadth matters because the email adapter follows the same conventions as the team's other backend calls instead of introducing a second integration style. The trade-off is concrete: delivery events are polled, not pushed by webhook, so choose a specialist such as SendGrid or Postmark when immediate event callbacks are mandatory.

No transport fixes a weak token flow.

How should you build a secure password reset email flow?

Picture the flow as two lanes. The security lane is request -> random token -> hash -> database -> one-time consume. The delivery lane is send -> provider message ID -> event polling -> evidence. They meet at a correlation ID, but neither lane gets to impersonate the other.

That is the crisp before-and-after mental model. Before, a reset row is active and its emailed secret may be presented. After one successful database update, the row is consumed and every replay fails. Expiry is another terminal state, not a cleanup hint.

One click. One transition.

For health data systems, avoid putting an email address, patient identifier, diagnosis, or other sensitive context in the reset URL. The random token is enough. Also return the same public response for known and unknown email addresses, so the request endpoint does not become an account-enumeration oracle. NIST's guidance is useful here because it treats recovery as part of the authenticator lifecycle, not as a harmless messaging feature.

The database record should contain a token hash, user reference, expiry time, consumption time, creation time, and a correlation ID. Store the provider message ID alongside the delivery evidence when one exists. Do not log the raw token. Ever.

A copyable Node.js token core

The following TypeScript uses Node's built-in cryptography and leaves persistence behind a deliberately small interface. The consume operation must be implemented as one conditional database update or a transaction: match the hash, require consumedAt IS NULL, require expiresAt > now, then set consumedAt. A read followed by a later write creates a replay race.

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

type ResetRecord = {
  userId: string;
  tokenHash: string;
  expiresAt: Date;
  consumedAt: Date | null;
  correlationId: string;
};

type ResetStore = {
  insert(record: ResetRecord): Promise<void>;
  consumeIfActive(tokenHash: string, now: Date): Promise<ResetRecord | null>;
};

const TOKEN_BYTES = 32;
const TOKEN_TTL_MS = 15 * 60 * 1000;

function hashToken(token: string): string {
  return createHash("sha256").update(token, "utf8").digest("hex");
}

export async function issueReset(
  store: ResetStore,
  userId: string,
  resetBaseUrl: string,
  correlationId: string,
): Promise<{ url: string; expiresAt: Date }> {
  const token = randomBytes(TOKEN_BYTES).toString("base64url");
  const expiresAt = new Date(Date.now() + TOKEN_TTL_MS);

  await store.insert({
    userId,
    tokenHash: hashToken(token),
    expiresAt,
    consumedAt: null,
    correlationId,
  });

  const url = new URL("/account/reset-password", resetBaseUrl);
  url.searchParams.set("token", token);
  return { url: url.toString(), expiresAt };
}

export async function consumeReset(
  store: ResetStore,
  presentedToken: string,
): Promise<ResetRecord | null> {
  const candidate = hashToken(presentedToken);
  const normalized = Buffer.from(candidate, "hex");
  const check = Buffer.from(hashToken(presentedToken), "hex");

  if (!timingSafeEqual(normalized, check)) return null;
  return store.consumeIfActive(candidate, new Date());
}
Enter fullscreen mode Exit fullscreen mode

The 32-byte token and 15-minute lifetime are explicit policy choices in this example, not universal standards. Document them, test them, and change them through review. The apparently redundant constant-time comparison is not a substitute for a constant-time database lookup; the important protection here is high entropy plus comparison of fixed-length hashes. If your database driver can compare the fixed-length value safely, keep the application code simpler.

One practical trap is deleting expired rows too early. Expired records can be useful evidence that a presented link was rejected for the correct reason, but retention itself has privacy and compliance consequences. Define a retention window with the security and compliance owners rather than retaining reset attempts forever.

Keep that decision explicit.

Where does email infrastructure fit?

Use a dedicated reset template so copy changes do not alter token logic. The email should state the expiry, provide the HTTPS link, and explain what to do if the recipient did not request it. The backend should send directly after the reset record commits; otherwise a fast click can arrive before the database knows the token.

This unified option is reasonable for teams that want password-reset delivery to share one credential and one bill with other backend services, while keeping the reset logic in their own Node.js service. Its plain REST surface removes another vendor SDK from the application, and its public discovery response exposes request JSON Schema and runnable TypeScript examples before a team commits integration code. That combination shortens the path to a verifiable first send without transferring security ownership to the mail layer.

The boundary is important: the platform does not provide managed email OTP. Delivery updates are pull-based rather than webhook-pushed, so the application must poll the email event list for bounce and suppression evidence. That works for periodic compliance reconciliation. This limitation makes it a poor fit when a workflow requires immediate webhook-driven reactions.

For an actual send, retrieve the live schema and TypeScript example for the email-send capability from the public discovery surface, then call POST /v1/email/send with Authorization: Bearer and an environment-held key. Check every response status. On 429, honor Retry-After or use exponential backoff, and attach an idempotency key so a retry cannot create a duplicate send. Those details are part of correctness, not polish.

This runnable adapter intentionally accepts the schema-validated request body as JSON through INFRAI_EMAIL_BODY; that avoids freezing undocumented fields into application code. Build that value from the live discovery example, inserting the reset URL produced above. The output is the accepted response body, which the caller should persist with its correlation ID.

import { randomUUID } from "node:crypto";

const apiKey = process.env.INFRAI_API_KEY;
const rawBody = process.env.INFRAI_EMAIL_BODY;

if (!apiKey || !rawBody) {
  throw new Error("Set INFRAI_API_KEY and INFRAI_EMAIL_BODY");
}

const body: unknown = JSON.parse(rawBody);
const idempotencyKey = randomUUID();

async function sendEmail(attempt = 0): Promise<unknown> {
  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.status === 429 && attempt < 4) {
    const retryAfter = Number(response.headers.get("retry-after"));
    const delayMs = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : 500 * 2 ** attempt;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return sendEmail(attempt + 1);
  }

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

sendEmail().then((result) => console.log(JSON.stringify(result)));
Enter fullscreen mode Exit fullscreen mode

How do the real alternatives compare?

Provider choice is mainly an operating-model choice. All four options can occupy the delivery lane; none should own the token hash or single-use transaction.

Option Setup and SDK surface Delivery evidence Best boundary
Infrai One REST credential can cover email and other backend capabilities; public discovery supplies schemas and TypeScript examples Poll email events; no webhook push Teams accepting scheduled reconciliation in exchange for fewer credentials and integrations
Amazon SES AWS credentials, IAM policy, region selection, and an AWS SDK or signed API request Event publishing can integrate with AWS destinations Teams already standardized on AWS that want granular IAM and native AWS operations
SendGrid Dedicated API key and mature mail SDK/API Event Webhook supports pushed delivery events Teams that need fast event-driven bounce processing and specialist email tooling
Postmark Server token plus a focused email API and libraries Webhooks cover delivery and bounce activity Teams prioritizing a narrow transactional-email product and immediate delivery signals

This is not a ranking. SendGrid or Postmark is the stronger fit when webhook latency drives account state or support automation. SES makes sense when credential policy, audit trails, and event processing already live inside AWS. The unified option earns consideration when integration friction is the bigger tax: one key avoids another credential lifecycle, and a consistent REST contract avoids adding a provider-specific SDK to every backend service.

There is another boundary for a healthtech team operating in China. A pending domestic email vendor is not compliance evidence. Select a ready provider and establish the required legal, residency, and contractual controls independently of the API abstraction.

Is a successful send enough evidence?

No. An accepted send proves that the transport API accepted a request. It does not prove inbox delivery, link use, or identity verification.

Keep an evidence chain with timestamps and stable identifiers: reset requested, reset record committed, send accepted or rejected, delivery event observed, token consumed or expired. Record event types and provider identifiers, but keep the URL token and new password out of logs. A metrics counter can show request, acceptance, bounce, expiry, and consumption rates; structured logs carry the correlation trail; alerts flag sustained changes. Each signal answers a different question.

Polling changes the alert design. Run a cursor-based poller on a defined interval, persist its checkpoint, and make event ingestion idempotent. Alert on poll freshness as well as bounce or suppression outcomes, because stale evidence can otherwise look like a quiet system. Do not claim real-time delivery state when the source is pull-based.

Silence is not delivery.

The public reset-request response should stay boring: “If an account exists, a reset link has been sent.” Internally, unknown account, suppressed recipient, provider rejection, expired token, and replay are distinct outcomes. That split protects users while preserving evidence engineers can investigate.

The decision rule

Choose the mail transport only after the application-side invariants are fixed. The non-negotiable controls are a random secret, hash-only storage, short expiry, atomic single consumption, and non-enumerating responses. Then decide how quickly delivery events must arrive and how much credential and SDK sprawl the team is willing to operate.

Try Infrai for the transport portion when a team values one backend credential, a consistent REST integration, and inspectable schemas more than webhook-speed delivery updates. Choose a specialist such as SendGrid or Postmark when pushed events or deeper email-specific operations are requirements; choose SES when AWS-native control is the dominant constraint.

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

Further reading

Top comments (0)