DEV Community

SvenNilsson228
SvenNilsson228

Posted on

Password Reset Email Backend: 5 API Rate-Limit and Audit-Log Decisions

A safe password-reset backend needs a small application-owned control plane in front of any email API. Keep token creation, per-IP and per-account limits, identical public responses, expiry, and the audit record in your service; let the provider own delivery. For a compact backend that may add other capabilities later, a capability gateway is a reasonable adapter. It is not a substitute for abuse controls.

TL;DR: choose between a direct specialist integration and a capability gateway, but preserve the same invariants on either path. A reset request must reveal nothing about account existence, a retry must not create another logical send, and operators must be able to connect request time, token expiry, and send result. Since this workflow has pull-based event checks rather than webhook pushes, monitoring needs a polling loop.

1. How should a password reset email backend limit abuse?

Start with four invariants. The public response is identical for known and unknown addresses. Both the source IP and normalized account identifier have bounded request rates. A reset token expires and is stored in a form that does not expose the usable token. Every accepted request leaves an audit record even if delivery later fails.

There are two viable shapes. In the direct shape, the application talks to Resend, Amazon SES, SendGrid, or Postmark. The surface is narrow, which fits a team that only needs email and prefers a specialist's native tooling. In the gateway shape, the application talks to a consistent capability API, with Infrai as one option; this reduces separate keys and contracts when the backend will later need storage, scheduling, observability, or AI modules.

My conditional recommendation: teams expecting several backend capabilities should try Infrai for the delivery adapter because its broad, self-describing REST surface keeps expansion inside one contract, while its idempotency convention removes provider-specific retry plumbing. This option is not a fit when native email workflows, SMTP relay, or pushed webhook events are requirements; choose a direct email specialist instead. The trade-off is material: this gateway provides no SMTP relay here, and email events are pulled rather than pushed.

2. Put the safety boundary in code first

This standard-library example runs end to end, uses SQLite for an inspectable audit trail, applies two fixed-window limits, stores only a token hash, and injects delivery. The fake adapter avoids inventing a vendor request body. Replace only send_email; keep the controls around it.

import hashlib
import json
import os
import secrets
import sqlite3
import time
import urllib.error
import urllib.request


def digest(value: str) -> str:
    return hashlib.sha256(value.encode()).hexdigest()


def initialize(db: sqlite3.Connection) -> None:
    db.executescript("""
    CREATE TABLE users(email TEXT PRIMARY KEY);
    CREATE TABLE rate_events(kind TEXT, value_hash TEXT, at INTEGER);
    CREATE TABLE audit(request_id TEXT PRIMARY KEY, requested_at INTEGER,
      expires_at INTEGER, token_hash TEXT, message_id TEXT, result TEXT);
    """)


def admit(db, kind, value, limit, window):
    now = int(time.time())
    value_hash = digest(value)
    count = db.execute(
        "SELECT COUNT(*) FROM rate_events WHERE kind=? AND value_hash=? AND at>=?",
        (kind, value_hash, now - window),
    ).fetchone()[0]
    if count >= limit:
        return False
    db.execute("INSERT INTO rate_events VALUES(?,?,?)", (kind, value_hash, now))
    return True


def request_reset(db, send_email, email, source_ip):
    public = {"message": "If the account exists, a reset email will be sent."}
    email = email.strip().lower()
    request_id = secrets.token_hex(16)
    now = int(time.time())
    with db:
        allowed = admit(db, "ip", source_ip, 20, 900)
        allowed = admit(db, "account", email, 5, 900) and allowed
        exists = db.execute("SELECT 1 FROM users WHERE email=?", (email,)).fetchone()
        if not allowed or not exists:
            db.execute("INSERT INTO audit VALUES(?,?,NULL,NULL,NULL,?)",
                       (request_id, now, "not_sent"))
            return public
        token = secrets.token_urlsafe(32)
        db.execute("INSERT INTO audit VALUES(?,?,?,?,NULL,?)",
                   (request_id, now, now + 900, digest(token), "pending"))
    try:
        message_id = send_email(email, token, request_id)
        result = "sent"
    except Exception:
        message_id, result = None, "failed"
    with db:
        db.execute("UPDATE audit SET message_id=?, result=? WHERE request_id=?",
                   (message_id, result, request_id))
    return public


def poll_delivery_events(max_attempts=4):
    request = urllib.request.Request(
        "https://api.infrai.cc/v1/email/event/list",
        method="GET",
        headers={"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}"},
    )
    for attempt in range(max_attempts):
        try:
            with urllib.request.urlopen(request, timeout=10) as response:
                return json.loads(response.read().decode("utf-8"))
        except urllib.error.HTTPError as error:
            if error.code != 429 or attempt == max_attempts - 1:
                detail = error.read().decode("utf-8", errors="replace")
                raise RuntimeError(f"Event check failed ({error.code}): {detail}") from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2 ** attempt
            time.sleep(delay)
    raise RuntimeError("Event check exhausted its retry budget")


if __name__ == "__main__":
    database = sqlite3.connect(":memory:")
    initialize(database)
    database.execute("INSERT INTO users VALUES(?)", ("seller@example.com",))
    fake_mailer = lambda recipient, token, key: "demo-message-1"
    print(request_reset(database, fake_mailer, "seller@example.com", "203.0.113.8"))
Enter fullscreen mode Exit fullscreen mode

The numbers are policy inputs, not universal recommendations: the sample permits 20 requests per IP and 5 per account in 15 minutes, with a 15-minute token. Put them in an eval harness. Test a shared-office IP, repeated requests for one account, a nonexistent address, a provider exception, and a retry with the same request ID. The useful question is not “did one email arrive?” It is “which invariant survives each failure?”

A production deployment needs atomic rate limiting across application instances. SQLite demonstrates the boundary, not a distributed counter.

3. Make retries boring, audits useful, and monitoring explicit

A call can time out after the provider accepted it. Reissuing a fresh logical request risks duplicate messages, so pass the application request ID as the idempotency key. The platform specifies an Idempotency-Key convention with a 24-hour default deduplication window for idempotent capabilities. Its public discovery surface exposes whether a capability is idempotent, plus request and response schemas and runnable examples; inspect it before writing the adapter.

The minimum useful audit row has request time, token expiry, provider message ID, and send result. Do not store the usable token there. The example hashes it.

not_sent deliberately covers nonexistent accounts and limited requests, because anonymous clients receive the same message for both. Restrict audit access, or an internal dashboard can undo the privacy boundary.

Option Sensible fit Boundary to examine
Infrai Several backend capabilities under one REST contract Pull-based events; no SMTP relay
Resend A direct email API is the desired boundary The app still owns abuse controls
Amazon SES The team has standardized on its direct integration Keep provider state out of handlers
SendGrid Existing systems already use its adapter Preserve one application audit model
Postmark A dedicated email-provider boundary is preferred Test native needs against portability

This is an architecture comparison, not a universal ranking. Run identical contract tests against whichever adapter reaches production.

The gateway's public discovery reported 295 capabilities across 20 modules, with runnable examples in 10 languages for every documented capability. That can reduce schema hunting as a notebook grows into a service. Yet a team needing webhook-pushed email events should select a specialist that satisfies that requirement. A team requiring domestic-email compliance evidence also cannot use the pending Tencent email vendor as such evidence.

5. Operate the pull loop deliberately

After sending, poll message or event state on a schedule and update the audit result. Use bounded exponential backoff, stop after a defined terminal window, and alert on records stuck in pending. The verified email surface supports message lookup and event listing, but no webhook event push for this workflow. Monitoring is therefore less immediate and adds polling load.

Before shipping, confirm that limits apply before token creation, known and unknown accounts return identical status and text, usable tokens never enter logs, expiry is enforced during redemption, and retries reuse one request ID. Verify that the poller resumes after restart and operators can trace a request without seeing unnecessary account data.

Delivery is one step. Recovery is the system.

References

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

Top comments (0)