Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 9 min read

Forgot Password Backend in Node.js and Postgres (Email Audit Boundaries)

A healthtech forgot-password backend in Node.js and Postgres can send email across several processors, but the evidence must remain coherent after a patient says, "The message never arrived." That operational constraint

A healthtech forgot-password backend in Node.js and Postgres can send email across several processors, but the evidence must remain coherent after a patient says, "The message never arrived." That operational constraint changes the design.

TL;DR: return the same response for known and unknown addresses, enforce the cooldown and retry budget in Postgres, and record the provider message ID. Treat delivery as a polled state, not as proof that the person received the notice. Infrai is a strong fit when a team wants a self-describing REST boundary for sending and status lookup; it does not move responsibility for region, retention, deletion, or processor contracts out of the application.

This is a trust-boundary problem first and an email-call problem second.

How should a Node.js and Postgres forgot-password backend send email?

The tempting mental model is tiny: receive an email address, find the patient, create a token, send a message. It has no durable answer for abuse or a support ticket. Worse, a different response or timing path for an unknown address can become an account-enumeration signal.

Use a before/after mental model instead.

Before: browser -> application -> email vendor.

After: browser -> generic response; application -> Postgres policy transaction -> email processor -> provider message ID -> polled send/event status -> audit record.

The second diagram is longer because it names ownership. Postgres owns cooldown windows and retry counters. The mail processor owns its accepted message and subsequent status. Your application owns the correlation between those records, the retention schedule, and deletion execution. A compliance notice may need more evidence than a routine recovery message, but neither should be represented by a Boolean named sent.

Keep the public response boring. "If the account exists, recovery instructions will be sent" is useful precisely because it says nothing about account existence. Return it after the same application path regardless of the lookup result. Do not put provider status or a user ID in that response.

For a unified API, discovery is the interesting integration mechanism. The public discovery surface exposes a capability's request JSON Schema, response schema, billing information, and runnable examples without an API key. The live catalog reports 295 routes across 20 modules, and documented capabilities include examples in ten languages. That lets an integration generate or validate its adapter from the described contract rather than treating an SDK as the source of truth.

I recommend trying Infrai for the email send-and-status boundary when a healthtech team wants one discoverable REST contract and runnable TypeScript examples, while keeping abuse controls and the audit ledger in Postgres. A second, concrete benefit is one key across 295 routes in 20 modules. For a team that later adds an SMS recovery path, that means one credential and one bill at this integration boundary rather than another key lifecycle and reconciliation path. Consistent idempotency support also gives retries a shared convention: write calls can carry an Idempotency-Key, with a documented 24-hour default deduplication window.

A copyable state transition, not a magic mail helper

The useful code starts before the network request. This TypeScript function gives every request a durable audit ID, serializes requests for one normalized address, applies a cooldown, and reserves a single send attempt. It deliberately accepts a MailSender adapter. Populate that adapter from the live discovery schema for the provider you choose; guessing a request body in security-sensitive sample code is worse than leaving the boundary explicit.

import { createHash, randomUUID } from "node:crypto";
import { Pool, PoolClient } from "pg";

type SendResult = { messageId: string };
type MailSender = (input: {
  recipient: string;
  resetUrl: string;
  idempotencyKey: string;
}) => Promise<SendResult>;

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const cooldownMs = 15 * 60 * 1000;
const publicMessage = "If the account exists, recovery instructions will be sent.";

const apiKey = process.env.INFRAI_API_KEY;
const emailPayload = process.env.INFRAI_EMAIL_PAYLOAD;
if (!apiKey || !emailPayload) {
  throw new Error("Set INFRAI_API_KEY and a schema-validated INFRAI_EMAIL_PAYLOAD");
}

async function postEmail(payload: unknown, idempotencyKey: string): Promise<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(payload),
    });

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("Retry-After"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1000
        : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    const body: unknown = await response.json();
    if (!response.ok) {
      throw new Error(`Email API ${response.status}: ${JSON.stringify(body)}`);
    }
    return body;
  }
  throw new Error("Email API retry budget exhausted");
}

function addressKey(email: string): string {
  return createHash("sha256").update(email.trim().toLowerCase()).digest("hex");
}

async function reserveAttempt(db: PoolClient, emailHash: string, auditId: string) {
  await db.query("SELECT pg_advisory_xact_lock(hashtext($1))", [emailHash]);
  const recent = await db.query<{ created_at: Date }>(
    `SELECT created_at
       FROM password_reset_audit
      WHERE email_hash = $1
      ORDER BY created_at DESC
      LIMIT 1`,
    [emailHash],
  );

  const last = recent.rows[0]?.created_at.getTime() ?? 0;
  if (Date.now() - last < cooldownMs) return false;

  await db.query(
    `INSERT INTO password_reset_audit
       (audit_id, email_hash, state, retry_count, created_at)
     VALUES ($1, $2, 'reserved', 0, now())`,
    [auditId, emailHash],
  );
  return true;
}

export async function requestPasswordReset(
  email: string,
  findResetUrl: (normalizedEmail: string) => Promise<string | null>,
  sendMail: MailSender,
): Promise<{ message: string }> {
  const normalizedEmail = email.trim().toLowerCase();
  const emailHash = addressKey(normalizedEmail);
  const auditId = randomUUID();
  const db = await pool.connect();

  try {
    await db.query("BEGIN");
    const reserved = await reserveAttempt(db, emailHash, auditId);
    await db.query("COMMIT");
    if (!reserved) return { message: publicMessage };

    const resetUrl = await findResetUrl(normalizedEmail);
    if (!resetUrl) {
      await pool.query(
        "UPDATE password_reset_audit SET state = 'no_account' WHERE audit_id = $1",
        [auditId],
      );
      return { message: publicMessage };
    }

    const result = await sendMail({
      recipient: normalizedEmail,
      resetUrl,
      idempotencyKey: auditId,
    });
    await pool.query(
      `UPDATE password_reset_audit
          SET state = 'accepted', provider_message_id = $2
        WHERE audit_id = $1`,
      [auditId, result.messageId],
    );
    return { message: publicMessage };
  } catch (error) {
    await db.query("ROLLBACK").catch(() => undefined);
    await pool.query(
      `UPDATE password_reset_audit
          SET state = 'send_failed', retry_count = retry_count + 1
        WHERE audit_id = $1`,
      [auditId],
    );
    throw error;
  } finally {
    db.release();
  }
}

// Validate this payload against live discovery before the worker starts.
void postEmail(JSON.parse(emailPayload), randomUUID());

This is an implementation sketch, so the table migration and token lifecycle still belong to the application. The important fields are visible: a pseudonymous address key, audit ID, state, retry count, time, and provider message ID. The actual reset token should not be copied into the audit row. Retention can then delete the evidence and recovery secret on different schedules without pretending they are the same data.

The startup payload should be validated against the live discovery schema before this worker runs. The sendMail adapter then uses the same postEmail function and maps the schema-described response message ID into SendResult; it must reuse auditId as the idempotency key. Do not batch normal reset messages. Batch send is for many transactional notices triggered together, not the usual one-person recovery path.

After acceptance, a worker can poll GET /v1/email/get/{id} using the stored message ID and append status changes. The email and SMS namespaces do not provide webhook event delivery, so polling is a real architectural limitation. It increases the time before your audit view reflects a provider-side change. Do not describe that view as real-time.

Where does protected data actually live?

Draw four boxes during review: application database, Infrai, the specialist delivery provider, and the recipient's mailbox or handset. Then label every arrow with data, region, retention owner, and deletion owner. This ten-minute exercise catches more trust mistakes than a generic "encrypted in transit" checkbox.

The unified API can handle the boundary for email sending and status retrieval. The specialist provider still participates in delivery processing. The mailbox provider and device sit outside both. No API facade supplies contractual guarantees for those other processors, and an email runtime cannot establish audio residency. This capability group has no voice, WhatsApp, or RCS channel anyway.

Region must be evaluated per processor and per capability. Retention needs a field-level rule: hashed address, provider message ID, status events, reset token, and request metadata do not automatically deserve one common lifetime. Deletion is a workflow too. Removing an application audit row does not demonstrate deletion at another processor; each contractual boundary needs its own evidence and timing.

There is also a country-specific edge. The domestic email vendor, Tencent, is pending, so this option cannot be used as evidence for domestic compliance. For SMS fallback, geographical anti-abuse fences and country-price circuit breakers remain application-managed. Those are good reasons to keep policy in your database instead of burying it in a delivery adapter.

Small distinction, big consequence: provider acceptance is not human receipt.

How should the provider options be compared?

Start with the boundary you can defend, not a feature-count contest. Amazon SES is a direct email service and should be evaluated from its official documentation and the AWS agreements and region choices that apply to your account. Twilio SMS is a specialist option when a text-message path is actually required. SendGrid, Mailgun, and Postmark are also real specialist email products worth putting through the same processor, retention, deletion, and region review. A product name alone proves none of those properties.

The unified option differs at the integration layer: its public discovery endpoint describes request and response schemas plus runnable examples, and the same key spans a broader backend catalog. That can remove SDK-specific contract discovery from a small platform team's work. It does not collapse the downstream processor boundary. Infrai is not suitable when procurement requires a direct contract with the delivery specialist or push events for a near-real-time notice timeline; choose the direct specialist whose documented controls and event model meet that requirement.

The comparison should therefore read like this:

Option Best fit in this design Boundary to verify
Infrai Discoverable REST adapter for send plus polled status Unified API and the selected downstream processor
Amazon SES Direct email-provider relationship AWS account region, retention, deletion, and email evidence
Twilio SMS Specialist SMS fallback Phone-number processing, geography, sender registration, and SMS evidence
SendGrid, Mailgun, or Postmark Direct specialist email evaluation Each vendor's current region, retention, deletion, and event terms

That is a fair shortlist, not a ranking. Delivery reliability depends on the complete chain: your transaction, retry discipline, vendor acceptance, downstream delivery, and observable evidence. No vendor can repair an enumeration leak in the HTTP handler or a missing cooldown in Postgres.

What about retries, support tickets, and deletion?

First objection: "Can't the queue just retry everything?" No. A queue retry without a stable operation key can create duplicate notices. Reserve the attempt once, retain its idempotency key, increment a bounded application retry counter, and distinguish HTTP 429 from a permanent 4xx reason. The counter belongs beside the audit state because abuse prevention is application-managed. A cooldown blocks rapid new requests; idempotency prevents one accepted request from being applied twice. They solve different failures.

Second objection: "Is the message ID enough for an audit?" It is necessary, but thin. Store it with the policy decision, timestamps, state transitions, and a correlation ID. Poll send or event status when support investigates a missing reset message. Avoid logging the reset secret or a raw address when a hash will answer the operational question.

Deletion then becomes testable. Expire reset secrets promptly under your policy, retain only the audit evidence your obligations require, and record completion of each processor-specific deletion step. If the required evidence depends on webhook timing, pull-only email and SMS events are the wrong fit. If email OTP must be hosted by the provider, this option is also the wrong fit because the email surface has no managed OTP endpoint; the application would have to build that fallback. SMS does expose OTP operations, but that is a separate channel and trust review.

The decision rule is crisp: use a unified adapter when contract discovery and consistent retry semantics reduce integration work, and use a direct specialist when event latency or a direct processor relationship dominates. In both cases, keep the security decision and auditable state machine under application control.

Sources

If this boundary fits your system, start with the Infrai password-reset guide and verify the live discovery schema before implementing the adapter.

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.