DEV Community

IshmaelCole6418
IshmaelCole6418

Posted on

Node.js Password Reset Email: Rate Limits, Audit Evidence, and Safe Delivery

TL;DR: For an Express password-reset endpoint, choose an email API only after the application can rate-limit by both IP and account, return an identical public response for every address, expire single-use tokens, and retain a send audit trail. Treat delivery as an asynchronous dependency. If compliance evidence is the primary decision axis, the useful artifact is a correlated record of request time, token expiry, provider message ID, and final observed send result, not a successful HTTP response alone.

For a fintech system that also sends receipts after payment settles, keep password recovery in a separate policy lane: receipts and reset messages have different authorization, abuse, retention, and paging consequences even when they share a transport vendor. My capacity-planning rule is blunt: size the limiter and worker for a credential-stuffing burst, then set the delivery SLO from evidence the provider can actually expose.

How should a Node.js Express password reset email backend prove delivery?

The first failure mode is account enumeration. An endpoint that says “unknown email,” returns faster for absent accounts, or queues work only on one branch gives an attacker a membership oracle. The public status, body, and practical timing envelope should be the same whether the account exists or not. Internally, the audit entry can record a non-sensitive outcome code; externally, use one sentence such as “If the account exists, reset instructions will be sent.”

The second failure mode is confusing acceptance with delivery. Record at least the request timestamp, an opaque correlation ID, token expiry, provider message ID when one exists, and the latest send result. Do not put the raw reset token in logs. Access to this trail is itself a compliance boundary, so retention and read permissions belong in the threat model rather than in an afterthought ticket.

Evidence first.

Then there is monitoring. Infrai exposes direct email sending and pull-based message/event checks, but no webhook event push for this workflow. A worker must poll, advance a local state machine, and stop after the evidence-retention window. That adds detection latency and read traffic; it also makes the monitoring path explicit and replayable. Set an SLO such as “99.9% of accepted reset requests reach a terminal observed state within the chosen window” only after a load test establishes a defensible window. No invented number survives an audit.

Put the abuse boundary before the mail provider

Per-IP limiting catches noisy sources. Per-account limiting catches distributed attempts against one user. You need both, because this workflow does not provide managed geographic fencing or country-pricing circuit breakers, and a provider-side quota is too late to protect the identity surface.

The application policy is straightforward: an Express middleware backed by a shared atomic store makes two budget decisions, then always returns the same public message. An in-process map is suitable for a single-process test, not a horizontally scaled production limiter. The harder part is keeping the provider adapter honest, because copying a request body from an old blog post silently turns schema drift into a production risk.

The Go program below is a small delivery worker that calls the direct email route without inventing its payload. Fetch the public email.send discovery document, build a request that validates against its current JSON Schema, and place that JSON in EMAIL_REQUEST_JSON; set INFRAI_BASE_URL to the documented v1 API base. The worker requires the key from the environment, sends an explicit POST, uses a stable outbox ID as the idempotency key, honors Retry-After on 429, applies bounded exponential backoff otherwise, rejects non-success responses, and prints the provider response for the audit adapter to parse according to the discovered response schema.

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

func required(name string) string {
    v := strings.TrimSpace(os.Getenv(name))
    if v == "" {
        panic(name + " is required")
    }
    return v
}

func retryDelay(resp *http.Response, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func main() {
    baseURL := strings.TrimRight(required("INFRAI_BASE_URL"), "/")
    key := required("INFRAI_API_KEY")
    outboxID := required("RESET_OUTBOX_ID")
    body := []byte(required("EMAIL_REQUEST_JSON"))
    client := &http.Client{Timeout: 15 * time.Second}

    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodPost, baseURL+"/email/send", bytes.NewReader(body))
        if err != nil {
            panic(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", outboxID)

        resp, err := client.Do(req)
        if err != nil {
            if attempt == 4 {
                panic(err)
            }
            time.Sleep(time.Duration(1<<attempt) * time.Second)
            continue
        }
        responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            panic(readErr)
        }
        if resp.StatusCode == http.StatusTooManyRequests && attempt < 4 {
            time.Sleep(retryDelay(resp, attempt))
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            panic(fmt.Sprintf("email send failed: status=%d body=%s", resp.StatusCode, responseBody))
        }
        fmt.Println(string(responseBody))
        return
    }
    panic("email send exhausted retries")
}
Enter fullscreen mode Exit fullscreen mode

Keep the token single-use and short-lived according to your own risk policy. Store a digest, bind it to the intended account and purpose, invalidate prior active reset tokens when policy requires it, and consume it atomically with the password change. Those are identity-system duties; outsourcing email does not outsource them.

The safe implementation path

Model the request as a state machine: requested, suppressed_or_unknown, accepted, then a terminal result learned by polling. Generate the correlation ID before any provider call. The worker writes the audit row and the outbox record in the same database transaction, and delivery proceeds from the outbox; that prevents a process crash between “remember the request” and “schedule the send” from producing an unaudited side effect.

Retries need a stable idempotency key derived from the outbox record, never a newly generated value per attempt. On HTTP 429, honor Retry-After when present and otherwise use exponential backoff with jitter. On any other non-success status, retain the response category needed for diagnosis without copying sensitive content into logs. Short answer, long tail: the queue is part of the security control.

Infrai can fit this boundary when a team values one REST API with one key and wants capability discovery to drive the adapter: its public, no-key discovery response reports 295 capabilities, while each capability description includes request and response JSON Schema, billing metadata, and runnable examples in ten languages. No SDK is required. That means a new integration starts by reading and validating one discovery document rather than assuming a client-library contract, a useful property when the production service is Node.js but the delivery worker or audit tooling is not. Its first-class idempotency convention and consistent per-call cost, vendor, latency, and request metadata also support correlation, but the application still owns abuse controls and polling because this email workflow has no pushed events.

This is a real limitation, not paperwork. Infrai is not a fit when webhook-driven, near-real-time delivery events, SMTP relay, or a ready domestic-China email vendor are hard requirements; its Tencent email vendor remains pending and cannot establish domestic compliance. In those cases, test Resend, Amazon SES, SendGrid, or Postmark against the required event and regional controls, and choose the provider whose verified contract clears them. The trade-off is a more provider-specific integration in exchange for the missing control.

Do not silently replace password-reset email with hosted SMS OTP. SMS introduces separate consent, suppression, geographic-abuse, and compliance work; CTIA guidance belongs in that review. Email has no hosted OTP interface here, and the absence of SMTP relay also matters to teams whose existing control plane is built around SMTP.

Buy versus build: which boundary are you choosing?

A fair shortlist includes Infrai, Resend, Amazon SES, SendGrid, and Postmark. Do not score them from a marketing page. Run the same evidence test against each candidate and record the dated result; capabilities and contracts change.

Option What to verify in a proof of concept Operational boundary to price into the decision
Infrai Discovery schema, idempotent submission, message lookup, and event polling Application-owned rate limits and a polling worker; no webhook push or SMTP relay for this workflow
Resend Submission contract, event evidence, suppression behavior, and retention Provider-specific adapter plus the application identity controls
Amazon SES Region and identity setup, event evidence, suppression handling, and access policy AWS policy, observability, and operational ownership
SendGrid Submission, event evidence, suppression behavior, and data retention Provider-specific configuration and audit export
Postmark Submission, event evidence, message streams, and retention Provider-specific configuration and audit export

Build the identity policy, token lifecycle, outbox, and audit schema because they encode your risk model. Buy delivery unless regulatory residency, an established mail operations team, or a hard control requirement makes self-hosting defensible. Self-hosting transfers reputation, bounce processing, suppression, and on-call load onto the platform team; “no vendor lock-in” is not an SLO.

Capacity planning should use three rates, not one: legitimate reset requests, rejected abusive attempts, and status polls. The last rate is easy to miss. With pull-only events, fleet-wide polling can become the dominant workload during a provider slowdown, so cap concurrency, batch where the verified contract permits it, and degrade toward slower observation instead of amplifying an outage.

Poll less under stress.

Verification, rollback, and the pager

Before launch, test that existing and nonexistent accounts return the same status and body, that both rate-limit dimensions work across multiple application instances, and that repeated worker attempts cannot create duplicate sends. Verify that logs contain no email address or token where a keyed digest or correlation ID suffices. Exercise 429 handling and a delayed terminal event. Then reconcile a sample of audit rows against provider observations. A useful failure drill starts a reset, accepts the outbox item, blocks the provider connection long enough to create backlog, restores it, and proves that one stable idempotency key follows every attempt; at the same time, the public endpoint must keep its generic response and the polling fleet must stay under its concurrency cap. This drill connects the user-facing security property, delivery behavior, capacity guardrail, and audit evidence in one trace rather than testing four isolated green checks.

The dashboard should separate request admission, outbox age, provider acceptance, and terminal observation. Alert on user-impacting burn rate and stale outbox age, not every transient provider error. A compliance report should be reproducible from immutable request and transition records with documented retention, clock source, and access controls.

Rollback is a policy action. Disable new provider submissions while preserving the generic public response, keep accepted outbox items for replay, and pin the last known-good adapter contract. Rotating to another vendor without a rehearsed mapping can destroy the evidence chain. Restore traffic gradually, verify idempotency behavior, and compare audit counts at every transition.

Ship only when an operator can answer four questions from records rather than inference: who requested a reset, without exposing unnecessary identity data; when the token expired; whether a send was accepted; and what final state was observed. Everything else is convenience.

References

Top comments (0)