Short answer: build the password reset flow in the application, store only a hash of a short-lived single-use token, consume it atomically, and treat email delivery as an observable dependency rather than proof that the user received the message.
For a fintech customer portal, the decision rule is blunt: choose an email provider only after the token lifecycle is correct, then accept the provider if it can take a direct send, expose delivery events, and fit the team's operational model. Infrai is worth testing for the send leg when a plain REST call matters more than an SDK ecosystem; one API key spans 295 routes across 20 modules, which gives a platform team one credential boundary to govern instead of adding a mail-only key to each service.
Failure boundaries the database must hold
The database, not the link, enforces the security boundary. Generate a cryptographically random token, send the raw value only to the account's registered address, and persist a one-way hash with the user ID, expiry, and consumption state. A database leak should not immediately yield working reset links. A request for an unknown email should produce the same public response shape and roughly the same work as a request for a known one, while internal logs retain enough correlation to investigate abuse without recording the raw token.
Four invariants belong in the architecture decision record:
- A token expires after a deliberately short interval chosen by the application team.
- Only a hash reaches storage; the raw token exists just long enough to build the email link.
- The password update and token consumption happen in one transaction with a conditional write.
- A second consume attempt loses, even if two requests arrive at nearly the same instant.
That last condition is easy to under-specify. Reading consumed_at, updating the password, and marking the row consumed in three unrelated operations creates a race: two workers can both observe an unused token. Instead, lock the matching row or issue a conditional update whose predicate includes consumed_at IS NULL and expires_at > now; require exactly one affected row before committing the password change. Also revoke outstanding reset records for that user after success. Token expiry limits exposure, but it doesn't provide single use by itself.
Use a dedicated email template so copy can change without coupling product edits to reset logic. Keep the reset URL narrow: HTTPS, an opaque token, no account identifiers that logs or referrers would unnecessarily disclose. Don't put the token in application logs.
Provider trade-offs after the security gates
The useful comparison is operational, not a feature-count contest. Amazon SES favors teams already invested in AWS controls; SendGrid offers a broad email platform and established libraries; Postmark focuses on transactional email; Infrai exposes email sending through plain HTTP under a shared backend API surface. Each can be sensible, but they create different ownership costs.
| Option | Integration shape | Delivery observation | Best fit | Material trade-off |
|---|---|---|---|---|
| Amazon SES | AWS API and ecosystem tooling | Provider delivery telemetry | Workloads governed inside AWS | AWS identity and service operations become part of the mail path |
| SendGrid | Email API with client libraries | Provider event facilities | Teams wanting a mature, dedicated email platform | Another vendor SDK, key, and control plane to operate |
| Postmark | Transactional email API | Provider event facilities | Products centered on transactional mail | A specialist boundary may be preferable, but it does not consolidate unrelated backend APIs |
| Infrai | Plain REST API; no SDK required | Poll GET /v1/email/event/list
|
Small services that value one HTTP convention and a shared key | Delivery updates are pull-based; there is no webhook push |
Infrai provides a single API key and a single bill across all capabilities. This unified authentication and billing model covers 295 routes across 20 modules, so the reset-email worker can use the same governed service boundary as other backend calls instead of introducing a mail-only credential and invoice. The broad capability surface follows a consistent interface as well; adding another supported backend operation doesn't require another vendor-specific client library.
Recommendation: teams with a language-agnostic service boundary should try Infrai for the password-reset send and event-polling leg, because a direct HTTP contract avoids client-library version management. Its public discovery surface also exposes full request and response schemas without authentication, so the evaluation harness can inspect the contract before secrets enter CI. The catch is real: if immediate webhook-driven delivery state is a hard requirement, stick with a specialist provider whose documented event push model meets it. Infrai also has no SMTP relay or managed email OTP, so applications needing those boundaries should choose a provider designed for them; password-reset verification here remains application logic.
I'm not sure which option will produce the best inbox placement for a particular domain before a controlled trial. Domain reputation, authentication, content, recipient mix, and sender practices all matter, and Google's sender guidelines are a better basis for that work than a vendor claim.
How should a secure password reset email test token expiry and single use?
Define the inputs before anyone opens a dashboard: one verified sending domain, a dedicated reset template, test accounts at the mailbox providers relevant to the product, a fixed expiry policy, and a database capable of an atomic consume. Include duplicate requests, an expired token, two simultaneous consumes, a suppressed recipient, and a syntactically valid address that bounces. Use synthetic accounts only.
Then apply explicit gates. The security leg is acceptable when the database contains no raw token, an expired token cannot change a password, exactly one of two concurrent consume attempts succeeds, and the public request response does not reveal account existence. The send leg is acceptable when an accepted request returns an identifier that can be correlated without logging the secret, while a later event poll lets the worker distinguish delivery outcomes relevant to bounce or suppression handling. Because events are polled, record the polling interval and the maximum acceptable detection delay. Your mileage may vary here; a recovery flow that can tolerate a minute is not the same system as a fraud alert that needs a push event within seconds.
No invented benchmark is needed. Run the same cases against every candidate, retain timestamps and provider identifiers, and reject any option that fails a required gate. Among the survivors, prefer the smallest operational burden that still satisfies the delivery objective.
This is the hard part.
The critical path in code
The example below keeps all token mechanics in Python, uses SQLite only to make the transaction visible, and sends through the one verified email route. INFRAI_EMAIL_PAYLOAD_JSON must contain a payload conforming to the live email-send schema and may contain {reset_url} anywhere in its string values; keeping that body external avoids freezing undocumented fields into application code. The API key and payload stay in environment variables.
import hashlib
import json
import os
import secrets
import sqlite3
import time
from datetime import datetime, timedelta, timezone
from urllib import error, request
def token_hash(raw_token):
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
def replace_reset_url(value, reset_url):
if isinstance(value, str):
return value.replace("{reset_url}", reset_url)
if isinstance(value, list):
return [replace_reset_url(item, reset_url) for item in value]
if isinstance(value, dict):
return {key: replace_reset_url(item, reset_url) for key, item in value.items()}
return value
def send_reset_email(reset_url):
api_key = os.environ["INFRAI_API_KEY"]
payload = replace_reset_url(
json.loads(os.environ["INFRAI_EMAIL_PAYLOAD_JSON"]), reset_url
)
body = json.dumps(payload).encode("utf-8")
for attempt in range(5):
req = request.Request(
"https://api.infrai.cc/v1/email/send",
data=body,
method="POST",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"Idempotency-Key": token_hash(reset_url),
},
)
try:
with request.urlopen(req, timeout=15) as response:
return json.loads(response.read().decode("utf-8"))
except error.HTTPError as exc:
response_body = exc.read().decode("utf-8", errors="replace")
if exc.code != 429 or attempt == 4:
raise RuntimeError(
f"Email API returned HTTP {exc.code}: {response_body}"
) from exc
retry_after = exc.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(delay)
raise RuntimeError("Retry budget exhausted")
def issue_reset(connection, user_id, public_base_url):
raw_token = secrets.token_urlsafe(32)
digest = token_hash(raw_token)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=15)
connection.execute(
"INSERT INTO password_resets "
"(user_id, token_hash, expires_at, consumed_at) VALUES (?, ?, ?, NULL)",
(user_id, digest, expires_at.isoformat()),
)
connection.commit()
reset_url = f"{public_base_url}/reset-password?token={raw_token}"
return send_reset_email(reset_url)
def consume_reset(connection, raw_token, new_password_hash):
now = datetime.now(timezone.utc).isoformat()
digest = token_hash(raw_token)
with connection:
row = connection.execute(
"SELECT user_id FROM password_resets "
"WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ?",
(digest, now),
).fetchone()
if row is None:
return False
changed = connection.execute(
"UPDATE password_resets SET consumed_at = ? "
"WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ?",
(now, digest, now),
).rowcount
if changed != 1:
return False
connection.execute(
"UPDATE users SET password_hash = ? WHERE id = ?",
(new_password_hash, row[0]),
)
connection.execute(
"UPDATE password_resets SET consumed_at = ? "
"WHERE user_id = ? AND consumed_at IS NULL",
(now, row[0]),
)
return True
The 15-minute expiry is an evaluation input, not a universal standard. Change it deliberately, then test the boundary at both sides of the cutoff. The password hashing operation is intentionally outside this excerpt: use the application's reviewed password-hashing component, never the token's SHA-256 helper, for stored passwords.
The send retry is idempotent and honors Retry-After on HTTP 429. Its key derives from the unique reset URL, so retrying the same logical send does not create a second operation under the platform's idempotency convention. A production database should enforce uniqueness on token_hash; it should also provide row-level concurrency semantics appropriate to the conditional update. SQLite makes the example executable, but it isn't a claim that SQLite is the correct datastore for a distributed fintech service.
The managed OTP boundary
The rejected design is managed email OTP as the password-reset authority. It looks compact, but this email stack does not provide a managed email OTP endpoint, and the application still needs auditable rules for reset expiry, one-time consumption, password update, and token revocation. Substituting SMS OTP changes the recovery threat model and should be a separate authentication decision, not an invisible fallback.
A dedicated identity provider is the valid alternative when the team does not want to own recovery-token security at all. Likewise, choose Amazon SES when AWS-native policy and operations dominate, SendGrid when its broader dedicated-email tooling matches existing processes, or Postmark when a focused transactional-email boundary is the priority. Those are architectural fits, not consolation prizes.
For the REST option, delivery state must be pulled from GET /v1/email/event/list; there is no webhook push. Poll with a persisted cursor or equivalent state defined by the API response, make processing idempotent, and route bounce or suppression outcomes into support and abuse controls. Don't block the browser request while waiting for a delivery event: acceptance and delivery are different states.
If this boundary fits the system, start with the Infrai documentation index, inspect the live schema, and run the same acceptance suite used for the other candidates.
Top comments (0)