DEV Community

XaviorCross6845
XaviorCross6845

Posted on

Secure Password Reset Email Flow: Single-Use Hashed Tokens and Auditable Delivery

Short answer: build the password reset as an application-owned, short-lived, single-use token transaction, store only its hash, and use the email provider strictly to deliver the link and report delivery events.

That boundary matters more than the vendor choice. A provider template can own the wording and layout, but it shouldn't own token creation, expiry, or consumption. For a fintech team, this keeps the security decision in the database transaction that already produces an auditable record, while copy changes remain independent of backend releases.

The practical recommendation is to use a direct email API with a dedicated reset template, then poll delivery events for bounce and suppression handling. Infrai is one reasonable fit because one plain REST API works over HTTP without installing an SDK, while its public discovery response describes the live request schema and includes runnable examples. SendGrid, Postmark, and Amazon SES belong on the same shortlist; template governance and delivery-event operations should decide among them.

How should a secure password reset email flow enforce token expiry and single use?

Start with two values: a cryptographically random token that goes into the link, and a deterministic hash that goes into the database. The raw token is a bearer secret. Anyone holding it can attempt the reset, so logging it, storing it, or putting it into an analytics event turns ordinary observability into credential exposure. Hashing also makes a database read insufficient to reconstruct a working link.

Use a short expiry chosen for the application's risk model. I'm not sure there is one universal duration that fits every fintech product; account value, support recovery procedures, and user access patterns change the answer. What shouldn't vary is enforcement: compare the expiry on the server, and consume the matching record atomically. A page that first checks a token and later marks it used has a race window in which two requests can both pass.

No exceptions.

Single use is a state transition, not a flag checked in application memory. Bind the token record to the user, purpose, creation time, expiry time, and consumption time. The audit record should identify the reset request without retaining the raw secret. Also return the same public response when an address is known or unknown; otherwise, the reset form becomes an account-enumeration endpoint before an email is ever sent. NIST's digital identity guidance is useful context for authenticator and recovery risk, but it doesn't choose the lifetime for your application. Treat the exact lifetime as a documented security decision, then test the boundary at one instant before expiry, exactly at expiry, and after expiry. Edge cases live there.

Keep template ownership separate from reset authority

A dedicated provider-side template lets product or compliance owners revise subject lines, legal wording, and layout without touching token logic. The backend supplies the reset URL and other approved variables; the template renders them. This is a clean ownership split, provided template publication has its own review and access controls.

The catch is that template convenience can blur responsibility. A template must never decide whether a token is valid, extend its life, or generate a replacement. Those are application operations. Likewise, changing visible link text doesn't change the destination policy enforced by the backend. Validate the reset base URL from trusted configuration and don't accept a return URL straight from an unauthenticated request.

For compliance-sensitive mail, keep the audit vocabulary precise. "Accepted by the sending API" is not "delivered," and "delivered" is not "read by the account holder." This stack exposes email events through polling rather than webhook push, so the audit process needs a cursor or other durable polling checkpoint and must tolerate repeated events. Near-real-time orchestration is not its strength. There is another boundary: managed email OTP isn't provided here. If the recovery policy requires an emailed verification code rather than a reset link, the application must implement that code lifecycle itself. Don't quietly substitute the SMS OTP behavior; the channel and threat model are different.

Poll elsewhere.

A database-backed Python example

The following example isolates the parts that must be exact: 32 random bytes for the bearer token, SHA-256 storage, an expiry, an atomic consume statement, an explicit POST /v1/email/send, bearer authentication, idempotency, and bounded retry behavior for HTTP 429. It accepts EMAIL_SEND_PAYLOAD_JSON because the email request fields must come from the current discovery schema, not from a stale article. That keeps the script runnable without inventing a payload contract.

In this example, I use the reset record ID as the idempotency key for the send. A retry for the same reset request therefore cannot accidentally represent a second logical send. The database stores the provider response for audit, but never the raw token.

import base64
import datetime as dt
import email.utils
import hashlib
import json
import os
import secrets
import sqlite3
import time
import urllib.error
import urllib.request
import uuid


DB_PATH = os.environ.get("RESET_DB_PATH", "password_resets.db")
RESET_BASE_URL = os.environ["RESET_BASE_URL"].rstrip("/")
INFRAI_API_KEY = os.environ["INFRAI_API_KEY"]
INFRAI_BASE_URL = os.environ["INFRAI_BASE_URL"].rstrip("/")
EMAIL_SEND_URL = f"{INFRAI_BASE_URL}/email/send"
TOKEN_LIFETIME = dt.timedelta(minutes=20)


def utc_now():
    return dt.datetime.now(dt.timezone.utc)


def connect():
    db = sqlite3.connect(DB_PATH)
    db.execute(
        """
        CREATE TABLE IF NOT EXISTS password_resets (
            id TEXT PRIMARY KEY,
            user_id TEXT NOT NULL,
            token_hash TEXT NOT NULL UNIQUE,
            created_at TEXT NOT NULL,
            expires_at TEXT NOT NULL,
            consumed_at TEXT,
            send_response TEXT
        )
        """
    )
    return db


def token_hash(raw_token):
    return hashlib.sha256(raw_token.encode("ascii")).hexdigest()


def retry_delay(response_headers, attempt):
    value = response_headers.get("Retry-After")
    if value:
        try:
            return max(0.0, float(value))
        except ValueError:
            parsed = email.utils.parsedate_to_datetime(value)
            return max(0.0, (parsed - utc_now()).total_seconds())
    return float(2 ** attempt)


def send_email(payload, idempotency_key, max_attempts=4):
    body = json.dumps(payload).encode("utf-8")
    for attempt in range(max_attempts):
        request = urllib.request.Request(
            EMAIL_SEND_URL,
            data=body,
            method="POST",
            headers={
                "Authorization": f"Bearer {INFRAI_API_KEY}",
                "Content-Type": "application/json",
                "Idempotency-Key": idempotency_key,
            },
        )
        try:
            with urllib.request.urlopen(request, timeout=20) as response:
                response_body = response.read().decode("utf-8")
                return json.loads(response_body)
        except urllib.error.HTTPError as error:
            error_body = error.read().decode("utf-8", errors="replace")
            if error.code == 429 and attempt + 1 < max_attempts:
                time.sleep(retry_delay(error.headers, attempt))
                continue
            raise RuntimeError(
                f"Email API returned HTTP {error.code}: {error_body}"
            ) from error
    raise RuntimeError("Email send retry budget exhausted")


def substitute_reset_url(value, reset_url):
    if isinstance(value, dict):
        return {key: substitute_reset_url(item, reset_url) for key, item in value.items()}
    if isinstance(value, list):
        return [substitute_reset_url(item, reset_url) for item in value]
    if isinstance(value, str):
        return value.replace("{{RESET_URL}}", reset_url)
    return value


def issue_reset(user_id, email_payload):
    raw = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode("ascii")
    record_id = str(uuid.uuid4())
    created_at = utc_now()
    expires_at = created_at + TOKEN_LIFETIME
    reset_url = f"{RESET_BASE_URL}?token={raw}"

    payload = substitute_reset_url(email_payload, reset_url)

    with connect() as db:
        db.execute(
            """
            INSERT INTO password_resets
                (id, user_id, token_hash, created_at, expires_at)
            VALUES (?, ?, ?, ?, ?)
            """,
            (
                record_id,
                user_id,
                token_hash(raw),
                created_at.isoformat(),
                expires_at.isoformat(),
            ),
        )

    send_result = send_email(payload, record_id)
    with connect() as db:
        db.execute(
            "UPDATE password_resets SET send_response = ? WHERE id = ?",
            (json.dumps(send_result), record_id),
        )
    return record_id


def consume_reset(raw_token):
    consumed_at = utc_now().isoformat()
    with connect() as db:
        cursor = db.execute(
            """
            UPDATE password_resets
               SET consumed_at = ?
             WHERE token_hash = ?
               AND consumed_at IS NULL
               AND expires_at > ?
            """,
            (consumed_at, token_hash(raw_token), consumed_at),
        )
        return cursor.rowcount == 1


if __name__ == "__main__":
    payload = json.loads(os.environ["EMAIL_SEND_PAYLOAD_JSON"])
    reset_id = issue_reset(os.environ["RESET_USER_ID"], payload)
    print(json.dumps({"reset_id": reset_id}))
Enter fullscreen mode Exit fullscreen mode

There is a deliberate failure boundary in that ordering. The reset row is committed before the API call, so a transient client interruption doesn't create an emailed link with no matching database record. If the send doesn't complete, an internal retry can reuse the same record ID and idempotency key. The public reset-request endpoint should still give a generic response; operational details belong in protected logs.

The sample replaces {{RESET_URL}} anywhere in a payload whose fields are deployment configuration. Before deploying it, read the public discovery document for the email-send capability and construct EMAIL_SEND_PAYLOAD_JSON against its full JSON Schema and runnable Python example. The discovery surface requires no key and describes method, path, request schema, response schema, billing, and examples. That lets a plain HTTP adapter follow the current contract without installing another vendor SDK. With Infrai, one key and one bill cover sending and event retrieval, which removes an extra credential-rotation and reconciliation boundary from this audit workflow; the platform's broader capabilities still shouldn't influence the reset-token design.

Compare providers at the template boundary

Provider selection comes after the security boundary is fixed. The table is intentionally about ownership and operations, not a feature-count contest. A team should verify each candidate's current template workflow and event contract during a short integration spike.

Option Sensible ownership boundary Decision pressure
Infrai Application owns token state; provider template owns reviewed copy Good fit for direct REST integration and schema-led adapter work; event updates require polling
SendGrid Application owns token state; keep reset copy in a separately governed template Stick with it when the existing mail program and operational tooling already center on its templates
Postmark Application owns token state; give messaging owners controlled template access Evaluate it when the team wants a focused transactional-mail workflow
Amazon SES Application owns token state; choose and govern a template strategy around the AWS integration Prefer it when AWS-native identity, policy, and operations are the stronger constraint

Infrai is not suitable when webhook-pushed email delivery events are mandatory, when SMTP relay is a hard requirement, or when a domestic China email vendor is required as compliance evidence. Tencent email support is pending, so it cannot support that last claim. In those cases, select a provider whose verified current contract meets the requirement. Existing systems should also resist migration for novelty's sake: if SendGrid, Postmark, or SES already satisfies template controls, deliverability operations, and audit retention, changing the send adapter adds risk without fixing the token flow.

Deliverability remains shared work. Google publishes concrete sender guidance, while bounce and suppression outcomes still need to feed application operations. Since event retrieval here is pull-based, poll GET /v1/email/event/list from a scheduled worker, store a durable checkpoint, and make event processing idempotent. Don't put that polling loop on the user-facing reset request.

Roll out without weakening recovery controls

Ship the token transaction before changing the mail provider. Add tests for expired, consumed, malformed, and concurrently submitted tokens; confirm that logs and analytics never capture the query-string secret. Then publish the dedicated template through its normal approval path and exercise the direct API adapter in a non-production environment.

Next, run the event poller with durable progress tracking and reconcile bounce or suppression states into the audit record. Keep the old sender available during a bounded migration, but issue each reset through only one adapter. Dual-sending turns a controlled recovery flow into two valid links and muddies the evidence trail.

Finally, review sender authentication and Google's sender guidelines with the team that owns the domain. Your mileage may vary on the right polling interval because the acceptable audit delay is a product and compliance decision, not an API constant. Record that decision. Then alert on stalled polling and unusual suppression outcomes without confusing an API acceptance record for final delivery.

Sources

Top comments (0)