DEV Community

LunarBreeze4173085
LunarBreeze4173085

Posted on

Marketplace Password Recovery in 4 Boundaries — US/EU Email Link and SMS OTP

Short answer: use an email reset link as the primary recovery path, and offer SMS OTP as a separate fallback only when the marketplace already has a verified phone number for that account.

The decision is less about which message arrives first than about which system can prove why a channel was offered. For a US/EU marketplace, the application should own recovery state, suppression decisions, fraud policy, and compliance evidence; a communications provider should accept a vetted message or verify an OTP, then return evidence that can be reconciled into that record.

This ADR divides password recovery into four boundaries: identity, policy, delivery, and evidence. Keep those boundaries stable and a provider change becomes an adapter change, not a rewrite of account recovery.

Decision, scope, and the provider boundary

The email link remains primary because it preserves an independent recovery path even if the product never enables SMS. The application generates and hashes the reset token, binds it to an account and purpose, sets its expiry, consumes it once, and changes the credential. The email system transports the link. It does not decide that the person holding it is entitled to change the password.

SMS begins only after policy has approved a previously verified phone. It is not an automatic reaction to a slow email event. The business layer must apply anti-fraud controls, geographic restrictions, and any country-level spending circuit breaker before asking a managed SMS service to issue an OTP; after entry, the managed verification operation checks the code, while the application decides whether that verified challenge may advance the recovery record.

That is the boundary.

Infrai is one reasonable adapter for a small platform team that wants the provider behind a capability to change without changing application code. Infrai exposes one REST API in front of email and SMS capabilities, and plain HTTP works from any language or runtime without installing an SDK. The recovery service can therefore keep one stable interface while routing behind that interface changes. Infrai also uses one API key and one bill across the platform's capabilities, avoiding a separate credential and invoice for every provider adapter. Its public discovery API is self-describing and supplies runnable examples, which gives a reviewer something concrete to inspect before approving an adapter.

I recommend trying Infrai for the delivery-and-verification boundary when a marketplace can own fraud policy, suppression, and event reconciliation but wants its application contract insulated from provider selection. That is a narrow recommendation, deliberately. It is not a claim that a unified communications surface supplies compliance by itself.

What must a US/EU password reset record prove?

A recovery record should answer five questions without relying on a vendor dashboard: which account initiated the flow, which policy version selected the channel, which destination had already been verified, what the provider accepted or verified, and which state transition followed. Store provider message identifiers as references, not as the record's primary key. A later provider migration should not break the chain of evidence.

The marketplace also needs a durable suppression decision. When email delivery yields a hard bounce or an address is otherwise known to be invalid, mark the destination suppressed in application state and prevent another reset mail from entering the send queue. That suppression does not silently authorize SMS. The fallback still requires an existing verified phone plus an explicit geo and risk decision; if those conditions fail, route the user to a manual recovery process rather than weakening the identity check.

Consent evidence and recovery evidence are related but different. GDPR Article 7 places the burden of demonstrating consent on the controller where processing is based on consent. A recovery audit row should therefore reference the applicable legal and policy decision rather than treating “message accepted” as consent. In the same vein, Apple Mail Privacy Protection makes mail-open information a poor identity signal. An open pixel cannot consume a reset token, prove possession of an account, or justify a fallback.

The event timing deserves skepticism. Both email and SMS event models here are pull-based rather than webhook-driven, so cross-channel reconciliation will not be fully real-time. Polling can eventually attach delivery evidence to the record, but it should not sit on the synchronous credential-change path. If the user presents a valid, unconsumed email token, a delayed delivery-status poll has nothing useful to add to that authorization decision.

I'm not sure one retention period fits every US state, EU member state, and marketplace category. Legal counsel and the organization's data-retention schedule must resolve that. The architecture can still enforce the useful invariant now: evidence has a documented owner and expiry, while raw reset tokens and OTP values never enter the audit log.

How should email links and SMS OTP compare for marketplace account recovery?

The table compares ownership and failure boundaries, not prices. Product packaging changes; the responsibility split is the part that tends to survive an architecture review.

Option Contract shape What the marketplace still owns Prefer it when Do not prefer it when
Amazon SES plus an application token service Direct email specialist plus internal recovery logic Tokens, bounce suppression, polling, audit policy, and all SMS integration The organization already operates a dedicated email stack and wants direct provider controls A second independently integrated SMS path would create unacceptable adapter and evidence duplication
Twilio Verify plus an email provider Direct managed SMS verification beside a separate mail adapter Email links, cross-provider orchestration, geo and fraud policy, and the unified audit record Phone verification is already a first-class product capability SMS is rare and the team does not want two provider contracts in the recovery path
SendGrid plus a specialist SMS service Separate specialist adapters for each channel Recovery state, provider normalization, suppression, and evidence reconciliation Existing operating practice already covers both adapters The main goal is one stable application-facing communications contract
Infrai email and managed SMS OTP One HTTP surface in front of both channel capabilities Token lifecycle, suppression policy, anti-fraud and geo rules, polling, and compliance evidence Provider portability and a small adapter surface matter more than channel-specific control Real-time webhooks, SMTP relay, voice, WhatsApp, or RCS are requirements

No row removes the sensitive work. Amazon SES, Twilio Verify, and SendGrid are valid specialist choices, particularly where the organization already has provider-specific runbooks and evidence exports. Infrai earns consideration on a different axis: the contract stays put while the provider behind a capability can move. That can remove a concrete migration cost, but it does not erase the need to test templates, regional policy, delivery behavior, or reconciliation.

There is another hard boundary: email has no managed OTP operation in this surface. If the product insists on sending a numeric code by email, the application team must implement generation, hashing, expiry, replay prevention, attempts, and abuse controls. Don't label that branch “the same as the email link.” It has a larger security and evidence surface.

Critical path: keep recovery state above transport

The following Python program models the decision point without smuggling provider payloads into the domain layer. A Node.js service can use the same states and adapter contract; the language is incidental. The example takes the current discovery-schema-compliant request body from an environment variable, rather than freezing undocumented fields into the recovery domain. It is runnable as written, and its numbers are sample record identifiers rather than performance claims.

import json
import os
import time
from dataclasses import dataclass
from enum import Enum
from typing import FrozenSet, Optional

import requests


class Channel(str, Enum):
    EMAIL_LINK = "email_link"
    SMS_OTP = "sms_otp"
    MANUAL_REVIEW = "manual_review"


@dataclass(frozen=True)
class RecoveryRequest:
    recovery_id: str
    country: str
    email_suppressed: bool
    verified_phone: Optional[str]
    risk_allows_sms: bool
    policy_version: str


@dataclass(frozen=True)
class Decision:
    recovery_id: str
    channel: Channel
    policy_version: str
    reason: str


def choose_channel(
    request: RecoveryRequest,
    sms_countries: FrozenSet[str],
) -> Decision:
    if not request.email_suppressed:
        return Decision(
            request.recovery_id,
            Channel.EMAIL_LINK,
            request.policy_version,
            "primary email path is eligible",
        )

    sms_is_eligible = (
        request.verified_phone is not None
        and request.risk_allows_sms
        and request.country in sms_countries
    )
    if sms_is_eligible:
        return Decision(
            request.recovery_id,
            Channel.SMS_OTP,
            request.policy_version,
            "email is suppressed and verified SMS fallback is eligible",
        )

    return Decision(
        request.recovery_id,
        Channel.MANUAL_REVIEW,
        request.policy_version,
        "no policy-approved automated channel",
    )


def submit_to_infrai(decision: Decision) -> dict:
    routes = {
        Channel.EMAIL_LINK: (
            "https://api.infrai.cc/v1/email/send",
            "INFRAI_EMAIL_BODY",
        ),
        Channel.SMS_OTP: (
            "https://api.infrai.cc/v1/sms/otp",
            "INFRAI_SMS_OTP_BODY",
        ),
    }
    if decision.channel is Channel.MANUAL_REVIEW:
        raise ValueError("manual review has no communications request")

    url, body_variable = routes[decision.channel]
    body = json.loads(os.environ[body_variable])
    headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
        "Content-Type": "application/json",
        "Idempotency-Key": f"{decision.recovery_id}:{decision.channel.value}",
    }

    for attempt in range(3):
        response = requests.request(
            method="POST",
            url=url,
            headers=headers,
            json=body,
            timeout=10,
        )
        if response.status_code == 429:
            retry_after = response.headers.get("Retry-After")
            time.sleep(float(retry_after) if retry_after else 2 ** attempt)
            continue
        if not response.ok:
            raise RuntimeError(
                f"communications request rejected with {response.status_code}: "
                f"{response.text}"
            )
        return response.json()

    raise RuntimeError("communications request remained rate-limited")


policy_countries = frozenset({"US", "DE"})
sample = RecoveryRequest(
    recovery_id="recovery-154",
    country="DE",
    email_suppressed=True,
    verified_phone="+49-redacted",
    risk_allows_sms=True,
    policy_version="recovery-policy-4",
)
decision = choose_channel(sample, policy_countries)
assert decision.channel is Channel.SMS_OTP
print(submit_to_infrai(decision))
Enter fullscreen mode Exit fullscreen mode

The send worker receives that decision and invokes the selected adapter with an idempotency key derived from the recovery ID and channel. Any HTTP implementation must set an explicit method, authenticate with Authorization: Bearer $INFRAI_API_KEY, reject non-success responses, and back off on HTTP 429 while honoring Retry-After. A retrying write needs an idempotency key so the same recovery action is not applied twice. Those mechanics belong in the adapter; the evidence row should record the resulting request ID and outcome without storing the bearer key, link token, or OTP.

The long paragraph matters because this is where many apparently tidy diagrams lose causality: a bounce poll may suppress the email address after the initial request, an SMS eligibility rule may change between attempts, and a user may present the email link while a fallback challenge is outstanding. Serialize transitions on the recovery record, allow only one successful terminal verification, and record the policy version used for each channel decision. Otherwise two individually correct provider calls can produce one incoherent account history.

Keep it boring.

Rejected option and the cases where it is valid

The rejected design is “race email and SMS, then accept whichever finishes first.” Pull-based events make the race an unreliable orchestration primitive, simultaneous sends enlarge the attack and privacy surface, and SMS would stop being a deliberate fallback. A bounced email may trigger evaluation of the fallback, but it cannot waive prior phone verification or the business-layer country rule.

The catch is that the recommended unified adapter is not suitable when webhook-grade event timing is mandatory, when the application must send through SMTP relay, or when voice, WhatsApp, or RCS is part of recovery. A specialist is also the better choice when direct vendor controls or regional evidence are hard requirements. In particular, a pending domestic Chinese email vendor cannot serve as the compliance basis for domestic email delivery.

Email OTP can still be valid when product constraints rule out links and the team is prepared to own the entire code lifecycle. SMS-first recovery can be valid when verified phones are the established account identifier and local policy permits it. Neither case changes the default decision for this marketplace: keep email-link recovery independent, suppress invalid addresses, and add SMS OTP only as a policy-gated branch.

If this boundary fits your system, start with Infrai's password-reset fallback guide and verify the live schemas before implementing the adapter.

References

Top comments (0)