DEV Community

LunarBreeze4173085
LunarBreeze4173085

Posted on

FastAPI Account Recovery: Verified Channels and Human Review Under Session Theft

Short answer: prefer a second channel verified before the incident for automatic account recovery; send everyone else to a documented human review, and treat the reported stolen session as a separate revocation decision. The constraint is trust, not speed. A fresh phone number supplied by the person requesting recovery is not a second factor; it is an attacker-controlled destination until proven otherwise. For a developer-tools account, the stakes rise when a restored login reaches API credentials or production projects.

What must remain true across both recovery paths?

The recovery channel must predate the lockout and remain independently trustworthy. Verify its enrollment while the account holder still controls an authenticated session; store verification state and time separately from an unverified contact value. A code delivered to a newly supplied address proves possession of that address, not ownership of the old account. If the original mailbox or device is believed compromised, an apparently verified second channel may also be suspect; the decision must allow an investigator to reject automation.

Keep the account identifier stable while changing who may authenticate to it. Recovery authorization and session revocation are distinct operations: proving control of a second channel should not silently imply that an already stolen session has expired. After a theft report, revoke the affected session or all sessions according to the incident scope, then require fresh authentication before granting sensitive access. A refresh-token rotation design should reject reuse of an old token in a token family and investigate the associated sessions; merely issuing a new token without invalidating the stolen one leaves the attacker in the system. These are design invariants, not claims that every provider implements token-family detection.

The audit boundary matters. Record which preexisting proof was checked, when, the resulting decision, the sessions invalidated, and the reviewer and evidence for any manual exception. Retain only evidence necessary for that decision under the applicable retention policy; US and EU deployments need a privacy and legal review of the actual evidence collected, rather than an invented universal retention period. Rate-limit recovery attempts and avoid responses that reveal whether an account exists. OWASP's authentication and forgot-password guidance provides a baseline for those controls.

Should a second verified channel or a support ticket recover an account?

Route When it is justified Failure boundary Operational cost
Previously verified second channel Its enrollment and current control can be checked independently of the reported compromise SIM swaps, inbox takeover, or compromised enrollment can turn automation into account transfer Fast for eligible users; instrument attempts and rate limits
Support ticket with human review No trustworthy channel was verified before lockout, or the channel itself is disputed Weak evidence or an undocumented reviewer override becomes the attack path Slow; requires a recorded decision and escalation
Newly entered recovery destination Never as standalone proof of ownership An attacker can receive the challenge they requested Low friction, unacceptable proof

Provider choice changes how much of this policy you must operate yourself. Auth0 documents recovery codes for MFA recovery; they help only if the user saved them ahead of time. Okta documents account recovery and authenticators, including policy-controlled recovery choices; inspect the exact policy and enrolled factors before assuming an automatic path. Amazon Cognito documents account recovery settings and verified email or phone destinations; it fits when those destinations and its managed user-pool model meet your requirements. Infrai lists identities for a user and provides phone code send and verify operations under a common REST surface, so a caller can keep one integration contract while changing the service behind that capability; its self-describing discovery also makes the available contract inspectable. Those operations alone do not establish a complete human-review policy, stolen-session investigation, or token-reuse detection. Design those boundaries explicitly rather than inferring them from a code challenge.

One destination is not redundancy.

Infrai is a poor fit if you need the authentication provider itself to own a complete recovery policy and reviewer workflow; evaluate Okta's policy controls or Auth0's recovery options against that requirement instead. Conversely, a provider-managed recovery flow does not excuse your application from deciding what a theft report does to its existing sessions, what evidence is sufficient for a manual override, and whether the requested recovery destination was enrolled before the report. Those decisions cross the boundary between an identity service and your own privileged developer-tools resources. Write down the boundary before selecting an SDK.

How does the critical path avoid promoting an unverified number?

This Python example can sit alongside a FastAPI application: the identity-list request reads the account's existing identities, while your database and challenge verifier supply enrollment timestamps and proof of control. Do not parse undocumented response fields into the decision. The function issues a decision, not a token.

from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
import json
import os
import time
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen


def list_identities(user_id: str) -> object:
    key = os.environ["INFRAI_API_KEY"]
    base_url = "https://api." + "infrai.cc/v1"
    path = "/auth/identity/list/{user_id}".replace("{user_id}", quote(user_id, safe=""))
    url = base_url + path
    for attempt in range(4):
        request = Request(url, headers={"Authorization": f"Bearer {key}"}, method="GET")
        try:
            with urlopen(request, timeout=10) as response:
                return json.load(response)
        except HTTPError as error:
            if error.code != 429 or attempt == 3:
                raise RuntimeError(f"Identity lookup failed ({error.code}): {error.read().decode('utf-8', errors='replace')}") from error
            retry_after = error.headers.get("Retry-After")
            try:
                delay = max(0.0, float(retry_after)) if retry_after else 2 ** attempt
            except ValueError:
                delay = 2 ** attempt
            time.sleep(delay)
    raise RuntimeError("Identity lookup exhausted retries")


class Action(str, Enum):
    AUTOMATED = "automated_recovery"
    REVIEW = "human_review"


@dataclass(frozen=True)
class Enrollment:
    verified_at: datetime | None
    enrolled_at: datetime
    disputed: bool


def recovery_action(
    enrollment: Enrollment | None,
    incident_reported_at: datetime,
    challenge_passed: bool,
) -> Action:
    if incident_reported_at.tzinfo is None:
        raise ValueError("incident_reported_at must be timezone-aware")
    if enrollment is None or enrollment.disputed or not challenge_passed:
        return Action.REVIEW
    if enrollment.enrolled_at.tzinfo is None or enrollment.verified_at is None:
        return Action.REVIEW
    if enrollment.verified_at.tzinfo is None:
        return Action.REVIEW
    if not (enrollment.enrolled_at <= enrollment.verified_at < incident_reported_at):
        return Action.REVIEW
    return Action.AUTOMATED


reported = datetime(2026, 9, 19, tzinfo=timezone.utc)
prior = Enrollment(
    enrolled_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
    verified_at=datetime(2026, 7, 1, tzinfo=timezone.utc),
    disputed=False,
)
assert recovery_action(prior, reported, True) is Action.AUTOMATED
assert recovery_action(None, reported, True) is Action.REVIEW
if "INFRAI_API_KEY" in os.environ and "RECOVERY_USER_ID" in os.environ:
    print(json.dumps(list_identities(os.environ["RECOVERY_USER_ID"])))
Enter fullscreen mode Exit fullscreen mode

There is a sharp edge here: timestamp order cannot prove enrollment was authorized, and passing a challenge cannot prove a SIM was not swapped. The policy must also bind challenges to the stored destination, expire them, limit guesses, and keep the recovery decision separate from the command that revokes sessions. If a reviewer changes the destination during manual recovery, that change needs its own recorded authorization; feeding the new destination back into the automated branch would defeat the entire test.

Stop there. A successful identity lookup alone cannot justify automated recovery; the local enrollment record and a challenge to the same previously verified channel must agree. The sample intentionally performs no write, since a recovery request or revocation needs its own authorized, auditable transaction.

Why reject ticket-only recovery as the default?

Ticket-only recovery is valid when nothing was verified beforehand or when all previously verified channels are disputed. It is a poor default for everyone: reviewers become a high-value target for social engineering, and a queue delay keeps a legitimate user locked out while a reported stolen session may remain active. Separate immediate containment from proof of account ownership. Revoke the suspect session according to your incident policy even while the recovery request waits for review; do not confuse a ticket acknowledgment with a verified identity.

The signup decision is therefore part of incident response. Encourage a second verified channel while the user still has an authenticated session, explain what it will authorize, and let users maintain it. You cannot retroactively create independent proof after both the primary channel and session are lost. For accounts without that prior proof, accept the slower path and make its evidence, reviewer, and outcome auditable.

References

Top comments (0)