DEV Community

ottoneumann8425
ottoneumann8425

Posted on

Auditing Password Reset Email API 429 Rate Limit Cooldowns

Treat a password-reset request as an auditable recovery workflow, not as a synchronous email send: accept the request without revealing account state, enforce a per-account repeat-request cooldown, and let a durable worker honor Retry-After after an HTTP 429 without creating a second live reset token. The deciding constraint in a customer-support system is that delivery can be uncertain while the security outcome cannot be.

Short answer: return the same generic response for existent and nonexistent accounts, create at most one active recovery challenge for the policy window, and record delivery attempts separately from that challenge. A 429 changes when the next attempt may run; it does not justify generating another link. A permanent bounce or an administratively confirmed invalid address closes the email delivery path and routes the case into the support team's verified account-recovery process.

This distinction gives the system three useful properties at once: users cannot create a retry storm by clicking "resend," workers can crash after a network call without corrupting the recovery state, and support staff can explain why mail stopped without reading secrets or reconstructing events from application logs. Exactly once delivery is unavailable across an HTTP boundary. Exactly once state transition is attainable with uniqueness constraints, conditional updates, and an append-only audit record.

How Should a Password Reset Email API Handle 429 Rate Limits?

The first invariant is non-enumeration. OWASP recommends a consistent message and response time for password-reset requests regardless of whether the account exists, so the public handler should acknowledge the request before exposing any delivery decision. The handler may enqueue internal work only for a matched account, but that branch must not change the observable response.

The second invariant is one usable challenge per account and recovery purpose. Store only a hash of a cryptographically random token, give it an expiry, make it single-use, and invalidate it after a successful reset. A repeated request should normally reference the same unexpired challenge or atomically replace it under a documented policy; blindly minting one token per click leaves several valid credentials in inboxes and makes incident review needlessly ambiguous.

The third invariant is that recipient validity and message delivery are different state machines. An HTTP 429 means the sending endpoint is rate-limiting requests. RFC 6585 says a 429 response may include Retry-After; RFC 9110 defines that field as either delay seconds or an HTTP date. Neither statement says the address is invalid. Conversely, an SMTP enhanced status such as 5.1.1 identifies a permanent address failure, while a temporary 4.x.x status does not justify permanent suppression. Conflating these signals turns a transient capacity event into an account lockout.

Stop there.

For a support organization, suppression also needs provenance. Record whether the evidence came from a permanent delivery status, an authenticated administrative action, or a later address correction; record the event identifier and time; never store the reset token in the audit payload. This is the same discipline expected of a ledger: a current balance is useful, but it is not a substitute for the entries that produced it.

Decision record and failure boundaries

The selected design is a transactional outbox plus a recovery-challenge table, a recipient-status table, and an append-only attempt log. The web transaction creates or finds the active challenge and inserts a uniquely keyed outbox item. A worker claims that item, checks suppression again, performs one remote send attempt, and then records the outcome. Bounce ingestion is a separate authenticated path because it arrives later and has different trust and replay boundaries.

The unit of idempotency is (account_id, purpose, challenge_generation), not an HTTP request ID supplied by a browser. Browser retries, two open tabs, and a support agent's repeat-send action can all represent the same desired effect. A database unique constraint makes that effect durable. Each remote attempt receives its own attempt ID for correlation, while the stable outbox ID is used as the idempotency key if the delivery interface supports one.

The failure boundaries are explicit. Consider one plausible chain rather than an isolated status code: two browser tabs submit a recovery request, the first transaction creates challenge generation 18 and its outbox item, and the second transaction encounters the uniqueness rule and returns the same generic acknowledgement. A worker leases that item and calls the remote endpoint. The connection then closes before a response arrives. No observer can infer from that socket error whether the remote system accepted the email, so the worker lets its lease expire and later repeats the call with the same outbox ID; it does not create generation 19. If the response is a 429, the adapter parses Retry-After and persists a future attempt time. If a permanent mailbox failure arrives afterward, the independently authenticated bounce path records its event ID, changes recipient eligibility, and prevents another delivery attempt. The challenge itself remains unconsumed until it expires or is used. This longer chain is why a single sent boolean is inadequate: rollback, lease ownership, uncertain acceptance, throttling, mailbox validity, and challenge consumption are separate facts, and collapsing them destroys the evidence needed for reconciliation.

Option Integration effort Behavior after an ambiguous timeout Audit quality Decision
Send inside the request handler Low initially Handler or client may duplicate work Split across request logs Rejected for recovery mail
Database outbox and worker Moderate Reclaims the same logical item Attempt history joins to one challenge Selected default
External durable queue plus database state Higher Depends on coordinated deduplication Good with stable cross-system IDs Valid when a queue is already operated

The table is deliberately about integration effort rather than a promise that one transport is intrinsically more reliable. An outbox uses the database transaction already required for the challenge. An external queue can absorb bursts and isolate workers, but introducing it solely for reset mail creates another credential, deployment, retention policy, and reconciliation surface.

Critical path in Go

The worker below shows the boundary that matters. Repository methods are assumed to use conditional updates: Complete, Defer, and Suppress succeed only while this worker owns the lease, and the outbox uniqueness rule prevents two live items for the same challenge generation. The sender accepts a stable idempotency key. Those contracts, rather than an in-memory retry loop, carry correctness across process restarts.

package recovery

import (
    "context"
    "errors"
    "time"
)

type Item struct {
    ID            string
    AccountID     string
    Recipient     string
    ChallengeID   string
    AttemptNumber int
}

type SendResult struct {
    Accepted          bool
    RateLimited       bool
    RetryAfter        time.Time
    PermanentAddress bool
    EvidenceID        string
}

type Sender interface {
    SendReset(ctx context.Context, recipient, challengeID, idempotencyKey string) (SendResult, error)
}

type Repository interface {
    IsSuppressed(ctx context.Context, recipient string) (bool, error)
    Complete(ctx context.Context, itemID, evidenceID string, at time.Time) error
    Defer(ctx context.Context, itemID string, next time.Time, reason string) error
    Suppress(ctx context.Context, itemID, recipient, evidenceID string, at time.Time) error
}

type Worker struct {
    repo   Repository
    sender Sender
    now    func() time.Time
}

func (w Worker) Deliver(ctx context.Context, item Item) error {
    suppressed, err := w.repo.IsSuppressed(ctx, item.Recipient)
    if err != nil {
        return err
    }
    if suppressed {
        return w.repo.Complete(ctx, item.ID, "suppressed-before-send", w.now())
    }

    result, err := w.sender.SendReset(ctx, item.Recipient, item.ChallengeID, item.ID)
    if err != nil {
        // The outcome may be unknown. Reuse the item and key; do not mint a token.
        return errors.New("delivery outcome unknown; lease may be retried")
    }

    switch {
    case result.Accepted:
        return w.repo.Complete(ctx, item.ID, result.EvidenceID, w.now())
    case result.RateLimited:
        return w.repo.Defer(ctx, item.ID, result.RetryAfter, "rate-limited")
    case result.PermanentAddress:
        return w.repo.Suppress(ctx, item.ID, item.Recipient, result.EvidenceID, w.now())
    default:
        return w.repo.Defer(ctx, item.ID, w.now().Add(time.Minute), "temporary-failure")
    }
}
Enter fullscreen mode Exit fullscreen mode

Parsing Retry-After belongs in the HTTP adapter because it depends on response headers and the adapter's clock. Accept delay seconds and HTTP dates, reject negative or malformed values, and cap the result with an operator-defined maximum so corrupted input cannot park an item indefinitely. If the header is absent, use bounded exponential backoff with jitter. The queue's next_attempt_at timestamp must be persisted; sleeping inside a worker wastes capacity and loses the schedule on restart.

Do not hold a database transaction open across the network call. Claim with a short lease, commit, call the sender, then conditionally write the result. The small window between remote acceptance and local completion is why the stable idempotency key matters. If the receiving interface cannot deduplicate, duplicate email remains a possible presentation outcome, though both messages should point to the same challenge and only one successful consumption can occur.

How should cooldowns and bounces interact?

A repeat-request cooldown is an abuse and workload control, not proof of delivery. It should be checked against the account or normalized recipient rather than the caller's IP alone, because distributed callers can evade an IP-only rule and shared networks can punish unrelated users. Keep the public response generic even during cooldown. Internally, record a cooldown_reused decision so a support investigator can distinguish "no new work was necessary" from "the address was suppressed."

Use separate times for separate claims: challenge_expires_at, next_attempt_at, repeat_request_allowed_at, and suppressed_at. One overloaded status plus updated_at cannot answer whether a link is valid, an email is queued, or the recipient is blocked. This is a common schema shortcut, and it fails precisely when an agent needs a defensible timeline.

Bounce processing must be idempotent too. Deduplicate an authenticated event by its source event ID, retain the raw classification needed for audit under the organization's data-retention policy, and map only well-understood permanent address failures to suppression. RFC 3463 separates persistent 5.x.x failures from persistent transient 4.x.x failures and defines X.1.1 as a bad destination mailbox address. A complaint or policy rejection may require a different action from an invalid mailbox, so do not flatten every failure into invalid_recipient.

Support agents need a narrow control surface: see the non-secret event timeline, correct an address through an authenticated process, and trigger reevaluation after verification. They should not be able to reveal token hashes, bypass the cooldown casually, or remove suppression without leaving an audit event. Compliance limits vary by jurisdiction and company policy; define retention, access, and deletion requirements with the responsible security and legal teams rather than treating an email log as permanent by default.

The rejected shortcut, its limitations, and where it still fits

The rejected option is sending synchronously in the reset endpoint and retrying 429 responses in that process. It is attractive because the integration is small: one handler, one API call, one visible result. It also couples user latency to rate-limit delays, loses durable retry intent on a process restart, and tempts the caller to issue a fresh token after a timeout.

The selected outbox design has limitations as well. It is not suitable for a prototype whose database cannot run conditional updates, and it adds migrations, a worker deployment, lease monitoring, cleanup, and reconciliation work. A team that already operates a durable queue may reasonably accept higher coordination effort and keep scheduling there; a low-risk internal tool with a mail sink may accept synchronous delivery. The trade-off favors an outbox here because production account recovery already needs a transactional challenge record and a support-visible audit trail, not because an outbox guarantees delivery.

It still has a valid use case. An internal prototype with no real accounts, no support workflow, and a disposable mail sink may choose synchronous sending because there is no durable security outcome to protect. The boundary should be written down. Before production, move token creation and outbox insertion into one transaction, add unique constraints and lease recovery, authenticate bounce events, and exercise crash points in tests.

The deployment test matrix should include duplicate browser submissions, concurrent support repeat-send actions, a 429 with both forms of Retry-After, a missing header, worker death before and after the remote call, replayed bounce events, temporary 4.x.x failures, permanent 5.1.1 failures, and an address correction that occurs while an item is leased. Reconciliation then becomes mechanical: every active outbox item has one challenge, every completed item has a terminal attempt, and every suppression has attributable evidence.

Measure queue age, attempts by outcome, lease recoveries, cooldown reuse, and suppressions by classified reason. Alert on age and reconciliation violations, not on raw 429 count alone; a short rate-limit burst that drains within policy is operationally different from a queue whose oldest recovery message keeps aging. Logs should carry opaque account and attempt identifiers, never reset URLs or tokens.

The final decision is modest: make the database authoritative for recovery intent, make retries resumable, and make recipient suppression evidence-based. This costs more integration work than a direct send, but it keeps throttling, invalid addresses, and human support actions from changing security state by accident.

References

Top comments (0)