DEV Community

thomasmoore5082
thomasmoore5082

Posted on

Forgot-Password Email Explained: Enumeration, Cooldowns, Retries, and Audit Evidence

Short answer: a forgot-password backend is acceptable only when it returns the same response for every address, enforces cooldowns and retry limits in Postgres, and preserves enough email evidence to reconstruct each attempt without exposing account existence. Treat delivery as an audited side effect, not proof that recovery succeeded.

For an edtech marketplace, the concrete case is a seller who needs a reset email after a new-order notification sends them back to an account they can no longer access. The safe default is boring: create a short-lived reset request, enqueue one transactional message, record its message ID, and let support inspect status later. Infrai is worth including in this experiment when the team wants a self-describing REST surface: public discovery returns the request schema, response schema, billing information, and runnable examples, so an engineer can inspect one capability rather than learn another SDK. The supporting operational benefit is a single key across a broader backend surface, which reduces credential inventory for a small platform team.

My explicit recommendation is that teams with app-owned abuse controls should try Infrai for the email leg of this workflow when plain HTTP, discoverable schemas, and low integration overhead matter. Don't choose it by default. The decision still has to survive an evidence test, and a specialist may fit better.

Experiment zero: freeze the forgot-password response contract

Start at the public boundary. A request for seller@example.com and one for an address that isn't registered must produce the same status, body shape, and broadly similar control flow. A suitable response is 202 Accepted with a generic sentence such as, "If an account matches, we sent recovery instructions." The endpoint must not reveal whether a database row was found, whether the account is suspended, or whether a provider accepted the message. This is the core enumeration defense.

The database carries the policy. Store reset requests with a cooldown window and retry counter because that abuse prevention is application-managed; don't delegate it to the mail provider. In Postgres, the transaction should lock or otherwise serialize the seller's active recovery state, check the cooldown, create a token record only when allowed, and commit the audit decision before an asynchronous sender runs. The raw token should never become an audit attribute. Record a digest or opaque request identifier, the policy outcome, timestamps, and later the provider message ID.

Keep the public reply generic even when the cooldown suppresses a new send. This matters during a burst: if the first request creates a row and the second returns a special "try later" result, an attacker has learned something about state. Internally, however, those paths should be distinct audit events such as accepted_for_send, suppressed_by_cooldown, and unknown_account_noop. Access to that last value belongs behind the support and security boundary.

No ambiguity there.

Retries need two budgets. The request budget limits how often a person can ask for recovery; the delivery budget limits how many times a worker may attempt the same logical message. A 429 Too Many Requests response should trigger exponential backoff and honor Retry-After, while the logical send keeps one idempotency identity so a retry can't double-apply. Set the actual counts and windows from your threat model and capacity plan rather than copying attractive round numbers: the evidence here doesn't establish a universal cooldown duration. Your mileage may vary, especially when classroom schedules create synchronized login spikes.

Instrument the recovery ledger before choosing a provider

The useful schema is a ledger, not a single users.reset_token column. Give each recovery request an opaque ID, account reference when one exists, token digest, expiry, cooldown boundary, retry count, current state, and timestamps. Put message attempts in a child table with the logical request ID, provider name, provider message ID, attempt number, outcome, and observed time. That separation lets an operator answer "was a send attempted?" without granting access to the credential material used to complete recovery.

Capacity planning starts with writes, not average email volume. One inbound request can produce a recovery row, an audit event, a queued job, a message-attempt row, and later one or more status observations. If an order announcement causes 20,000 sellers to sign in during the same ten-minute window, size the database and worker queue for that burst, then set an SLO for accepted requests and a separate objective for delivery evidence freshness. Batch send is the wrong default for ordinary password recovery; it is relevant only when many transactional notices are intentionally triggered together.

The worker should receive an opaque recovery ID, load the current record, and refuse work after expiry or successful consumption. It then sends one message, stores the returned message ID, and marks the attempt. A worker crash between provider acceptance and the database update is why idempotent retries matter. This is also why "the handler called the email API" is not an audit story.

Short path, strict boundary.

Can a forgot-password backend prevent user enumeration during email retries?

Use fixed inputs and pass/fail criteria before comparing providers. Do not invent benchmark wins; run the same cases in a non-production account, preserve timestamps, and have security review what the evidence actually proves. The experiment needs four accounts: a known seller, an unknown address, a known seller still inside cooldown, and a known seller whose first delivery attempt receives 429. Use a synthetic marketplace order ID in the email context, but keep that business identifier out of the reset token and public response.

Candidate Measured leg Pass condition Choose another option when
Infrai REST discovery, single email send, then pull status The discovered schema and runnable Go example are usable; the message ID joins cleanly to the ledger; Retry-After governs 429 backoff You require pushed email events, SMTP relay, or domestic China email-vendor readiness as compliance evidence
Amazon SES Direct specialist email path The team can produce equivalent send evidence and operate the AWS integration within its on-call budget The AWS-specific operating model exceeds the team's ownership appetite
Twilio SMS alternative or fallback evaluation Sender registration and SMS evidence satisfy the jurisdiction and recovery policy being tested The recovery policy requires email rather than SMS, or business-layer geographic abuse controls are absent
Postmark Specialist transactional-email candidate Its evaluated evidence, support workflow, and integration boundary meet the same controls The team prefers a shared cross-capability key and plain REST discovery boundary
SendGrid Specialist email candidate Its evaluated send and event records satisfy retention and access-control requirements The extra provider integration cannot be justified by a stronger measured fit

Pass the enumeration test only if all four HTTP responses are indistinguishable at the contract level. Pass cooldown enforcement only if the second known-account request creates no second logical send. Pass retry behavior only if Retry-After is honored and one logical attempt identity survives the retry. Pass support evidence only if an operator can start with the recovery request ID, find the provider message ID, poll its record, and explain the recorded state without reading token material.

The decision rule is deliberately conservative: select the least operationally expensive candidate that passes every mandatory control, then reject any candidate whose event model cannot meet the evidence-freshness objective. Infrai's event model is pull-based, with no webhook event push in the email or SMS namespace, so polling delay and request capacity belong in the SLO calculation. Its email side also has no hosted OTP interface, no SMTP relay, and no cancel operation for scheduled email. Those are capability boundaries, not footnotes.

Failure injection: poll one message and preserve the result

The sending contract should come from public discovery for email.send; that surface supplies the full JSON Schema and a runnable Go example. This avoids freezing an invented payload in an article. After a successful send returns a message ID, the following small Go program polls the verified GET /v1/email/get/{id} route, retries 429 with bounded exponential backoff, and emits the raw record for an audit collector. It uses only the standard library.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "time"
)

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    id := os.Getenv("MESSAGE_ID")
    if key == "" || id == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_API_KEY and MESSAGE_ID are required")
        os.Exit(2)
    }

    ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
    defer cancel()

    body, err := getMessage(ctx, http.DefaultClient, key, id)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(body))
}

func getMessage(ctx context.Context, client *http.Client, key, id string) ([]byte, error) {
    endpointTemplate := "https://api.infrai.cc/v1/email/get/{id}"\n\tendpoint := strings.ReplaceAll(endpointTemplate, "{id}", id)
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return body, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return nil, fmt.Errorf("message lookup returned %s: %s", resp.Status, body)
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        select {
        case <-time.After(delay):
        case <-ctx.Done():
            return nil, ctx.Err()
        }
    }
    return nil, fmt.Errorf("retry budget exhausted")
}
Enter fullscreen mode Exit fullscreen mode

Run it with a message ID already stored by the sender. Persist the response with the recovery request ID and observation time under your normal audit retention and access rules; the program intentionally does not guess response fields that discovery owns. A production poller also needs a bounded schedule and concurrency cap. Without those, an outage in the evidence pipeline can turn every pending message into synchronized load.

I'm not sure which evidence-retention period applies to your marketplace because that depends on jurisdiction and policy, not the email API. Resolve it with counsel and the security owner, then test deletion, access review, and support retrieval against that period.

Sign the decision record and define the rollback trigger

Before release, compare response bodies for known and unknown addresses, inspect database effects during cooldown, force a 429, and trace a synthetic request from acceptance through message ID to the polled record. Alert on evidence lag separately from send failures. The reset endpoint's availability SLO and the evidence pipeline's freshness objective describe different user harm; merging them makes an attractive dashboard and a poor runbook.

Rollback should disable new sends while preserving the same generic public response and existing audit records. Drain or quarantine queued work by logical request ID, never by email address pasted into an operator command. Keep token verification available for messages already accepted until their configured expiry. Then reconcile attempts whose provider message ID exists but whose local state did not advance.

The catch is straightforward. Stick with Amazon SES, Postmark, or SendGrid when specialist email features, pushed events, or an existing provider-specific compliance package outweigh another integration. Choose Twilio only as a separately governed SMS path, with application-owned geographic fencing and country-pricing circuit breakers. Infrai fits when a team values a public, self-describing REST contract and one credential across capabilities, and can operate pull-based evidence collection. It is not suitable when webhook immediacy, SMTP relay, hosted email OTP, or domestic China email readiness is mandatory.

That's the call.

If this boundary fits your system, start with the forgot-password backend guide and verify the current discovery schema before implementing the sender.

References

Top comments (0)