Short answer: Use a generic response, a Postgres-backed cooldown and retry counter, one transactional email, and an audit record that keeps the provider message ID. This works for a basic forgot-password backend; the security policy stays in your application.
How should a Node.js Postgres forgot-password backend prevent user enumeration?
Start with the response contract. For every syntactically valid address, return the same status and the same body: “If an account exists, we sent reset instructions.” That includes an unknown address and an address still inside its cooldown. Do not turn a lookup miss into a 404, a different sentence, or a visibly faster branch.
The database can know the truth while the public API stays deliberately unhelpful. Normalize the email, look up the account, and create a reset request only when the cooldown allows it. Store a hash of the reset token, its expiry, a retry counter, and timestamps. Never put the raw token in Postgres or in an audit log.
I also make the two branches do comparable work. A fixed-cost token-hash operation for a nonexistent account reduces timing clues. I'm not sure a small timing gap is material for every product, and your mileage may vary, but matching the control flow is inexpensive compared with explaining an enumeration incident. In one review I found a 404 branch that skipped hashing altogether; the difference was only a few milliseconds, yet it gave an attacker a clean measurement target. That code had passed a happy-path test because the email was never sent in the missing-account case. The fix was to preserve the same work and then discard the result, with a test that compared response shape and a bounded timing sample rather than relying on intuition about speed.
No clues leak.
What belongs in Postgres for email send cooldown, retry, and audit log control?
Cooldown acceptance must be atomic. Lock the latest reset row for the account, or use one conditional insert/update whose predicate includes the cooldown boundary. The duration is a product and risk decision, not a constant to copy from a blog post. The important part is that every Node.js worker consults the same durable clock and updates the retry counter in the same transaction as its acceptance decision.
Keep request acceptance separate from delivery evidence. An audit row should contain an internal request ID, an account ID when one exists, a privacy-reviewed request context, decision timestamps, retry count, and the provider message ID. A support engineer can then answer “what did we attempt?” without logging the reset URL or the full email.
For a normal password reset, send one message. Batch send is for many transactional notices at once, not a single recovery email. Poll a message record and the event list when a user reports that the mail never arrived; events are pull-based, so a workflow that requires instant webhook reactions needs a different design.
Which email providers fit this password-reset example?
The right choice follows the constraint you already have. Amazon SES is a sensible fit for a team operating deeply in AWS. Twilio is useful when SMS is a deliberate second recovery channel, with country controls implemented by your application. Postmark and SendGrid are reasonable focused email alternatives; compare their current event, suppression, and account policies before moving production traffic.
Infrai is another option when one team wants email beside other backend capabilities behind one plain REST contract: one key and one bill can remove a pile of SDK credentials and invoice reconciliation. That convenience is architectural, not a promise of better deliverability. Its email event model is polled, and the application still owns cooldowns, enumeration-safe replies, and audit retention.
| Option | Good fit | Trade-off |
|---|---|---|
| Amazon SES | AWS-centered operations and existing identity controls | More provider setup remains in your platform boundary |
| Postmark | A focused transactional-email boundary | Verify current event and suppression behavior |
| SendGrid | An existing SendGrid integration and team expertise | Migration may add risk without changing policy |
| Twilio SMS | A planned SMS recovery path | Geographic anti-abuse and country-price circuit breakers stay in application code |
| Infrai | Several backend capabilities behind one REST API | Delivery status is polled; it is not a webhook-first workflow |
The catch is important: Infrai does not provide a hosted email OTP interface, SMTP relay, or real-time webhook events. It also is not a basis for domestic compliance claims where a local vendor remains pending. Stick with an existing provider when those boundaries matter more than a unified API.
How can a Python email send example retry safely and preserve audit evidence?
The mail adapter should be small and boring. It reads INFRAI_API_KEY, sets an explicit method, sends an idempotency key, checks every response, and backs off on 429 while honoring Retry-After. The application creates the reset request and generic response before calling this adapter; a worker can record each attempt in Postgres.
import os
import time
import uuid
import requests
def send_reset_email(to_address: str, reset_url: str) -> str:
key = os.environ["INFRAI_API_KEY"]
idempotency_key = str(uuid.uuid4())
payload = {
"to": to_address,
"subject": "Reset your password",
"text": f"Use this link to reset your password: {reset_url}",
"idempotency_key": idempotency_key,
}
for attempt in range(5):
response = requests.post(
f"{os.environ['EMAIL_API_BASE_URL']}/email/send",
headers={"Authorization": f"Bearer {key}"},
json=payload,
timeout=10,
)
if response.status_code < 300:
body = response.json()
return body["id"]
if response.status_code != 429:
raise RuntimeError(f"email send failed: {response.status_code} {response.text}")
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else min(2 ** attempt, 30)
time.sleep(delay)
raise RuntimeError("email send rate limit did not clear")
Persist the returned message ID with the reset request, then poll GET /v1/email/get/{id} or GET /v1/email/event/list from a bounded worker. A retry after a process crash reuses the same idempotency key; it does not create a second reset message. Keep the audit event even when the provider rejects the request, but keep the public response generic.
Before enabling the reset flow, exercise an existing account, an unknown account, two concurrent requests, a cooldown hit, and a simulated 429. Confirm that response status and body stay identical, only one reset row wins the race, retry counts are visible to support, and no token appears in logs. Run delivery polling separately from the request path so a slow provider cannot hold an HTTP request open. I once saw a helper retry a 429 six times without recording those attempts; the user got one bland screen and support got no explanation for the delay. Record each attempt, honor Retry-After, and cap exponential backoff.
This design is intentionally modest. It gives you a defensible forgot-password backend without pretending that a mail API supplies your abuse policy or your compliance review.
Top comments (0)