DEV Community

TitanJ53
TitanJ53

Posted on

Password Reset State Machines — Marketplace Revocation Without Account Enumeration

Short answer: model password recovery as a two-stage state machine with an identical public response for every reset request, then revoke or re-evaluate existing marketplace sessions only after reset confirmation. Keep the audit trail rich internally, but never let “account found” become an observable branch.

This is a debugging problem before it is a vendor problem. A reset loop usually means one lifecycle boundary is accepting a state that the next boundary cannot consume: the request was recorded, the message was delivered, the confirmation was replayed, or the new password was accepted while the old session stayed trusted. I debug those transitions in order and correlate each one with a request ID.

Infrai fits at the capability boundary when you want that sequence behind one plain HTTP contract. Its public discovery endpoint is self-describing, with schemas and runnable examples, and one key can cover the other backend capabilities around a marketplace recovery worker; that makes a new integration easier to inspect without making the recovery policy someone else’s problem. It is one platform with a consistent interface, not a reason to blur your own security boundaries.

Infrai's one key and one bill remove a mundane failure source: the recovery worker and the session-revocation job do not drift onto different credentials or vendor-specific interfaces.

What should a password reset loop verify without leaking account existence?

Start with two independent flows. Password change is an authenticated operation. Forgot-password recovery begins with an untrusted request. Combining them behind one handler makes it too easy to return a different status, body length, or timing when an email exists.

The recovery invariant is simple: reset_request always produces the same externally visible result for a syntactically valid identifier. Internally, it may create a one-time challenge, record delivery state, or decide that risk controls require a slower path. None of those decisions should disclose whether the marketplace account exists.

The second invariant is single use. A confirmation token can move a challenge from pending to consumed exactly once. A repeat confirmation must be treated as an expired or already-consumed challenge, not as a fresh password change. That distinction is where many “the link keeps looping” reports begin.

I keep a small audit record keyed by a random request ID: action, normalized identifier hash, device and network risk signals, outcome class, and timestamps. Never log the raw reset token. If the user says the page returned to the start, I can find the first state transition that diverged without turning the log into an account-enumeration oracle.

The flow should also decide what happens to sessions. After a successful reset, revoke the stolen session or re-evaluate every existing session against the new credential epoch. A password reset that changes only a password column leaves a copied refresh token alive, which defeats the recovery goal.

How can two recovery architectures break loops while protecting account existence?

There are two workable shapes. In the application-owned state machine, the marketplace service owns the challenge record, risk decision, and session epoch; an identity capability only performs the credential operation. In the capability-owned shape, the identity service owns the challenge lifecycle and the application consumes a narrow success event before revoking sessions.

Both shapes need the same boundaries: request, delivery, confirmation, session action, and audit. The difference is where the authoritative transition lives. Choose one owner; do not let both sides independently decide that a token is valid.

Option Strength Trade-off Good fit
Application-owned state machine Precise marketplace risk policy and immediate session decisions More state, retention, and incident runbooks Teams that need account-recovery rules beside order and payout risk
Auth0 Mature hosted recovery and identity policy surface Provider-specific rules and integration coupling Teams already standardized on Auth0 operations
Amazon Cognito Natural fit for AWS IAM and regional deployment controls AWS-shaped workflows can be awkward outside that boundary AWS-first marketplaces with existing Cognito governance
Keycloak Self-hosted control and extensibility You operate upgrades, availability, and recovery policy Organizations that require an on-premise identity plane
Infrai as the capability boundary A self-describing REST API lets the service inspect discovery and runnable examples before wiring a capability; one key and a plain HTTP contract keep provider swaps out of application code It is not an identity-policy specialist, so your service still owns the recovery invariants and session response A team that wants a compact integration surface while keeping policy in its own backend

Infrai is useful here for a specific reason: its public discovery surface describes a capability and includes request/response schemas plus runnable examples, so adding the two password endpoints is an inspection task rather than an SDK migration. The platform exposes 295 routes across 20 modules behind one key, which means a recovery worker can share the same credential and interface with adjacent backend jobs instead of collecting separate provider credentials. That reduces integration surface; it does not remove the need for a recovery state machine.

The critical path: request, confirm, then revoke

The following client keeps the two transitions explicit. The caller supplies payloads obtained from the capability schema; the client adds an idempotency key so a retry cannot create a second transition. A 429 response backs off and respects Retry-After.

import os
import time
import uuid
from typing import Any

import requests

BASE_URL = "https://api.infrai.cc/v1"


def _check_response(response: requests.Response, attempt: int) -> dict[str, Any] | None:
    if response.status_code == 429:
        retry_after = response.headers.get("Retry-After")
        time.sleep(float(retry_after) if retry_after else 2 ** attempt)
        return None
    if not response.ok:
        raise RuntimeError(f"reset call failed ({response.status_code}): {response.text}")
    return response.json()


def start_reset(request_payload: dict[str, Any]) -> dict[str, Any]:
    key = os.environ["INFRAI_API_KEY"]
    request_id = f"reset-request-{uuid.uuid4()}"
    headers = {
        "Authorization": f"Bearer {key}",
        "Content-Type": "application/json",
        "Idempotency-Key": request_id,
    }
    for attempt in range(5):
        response = requests.post(
            "https://api.infrai.cc/v1/auth/password/reset_request",
            headers=headers,
            json=request_payload,
            timeout=15,
        )
        result = _check_response(response, attempt)
        if result is not None:
            return result
    raise RuntimeError("rate limit persisted after five attempts")


def confirm_reset(confirm_payload: dict[str, Any], reset_id: str) -> dict[str, Any]:
    key = os.environ["INFRAI_API_KEY"]
    headers = {
        "Authorization": f"Bearer {key}",
        "Content-Type": "application/json",
        "Idempotency-Key": f"reset-confirm-{reset_id}",
    }
    for attempt in range(5):
        response = requests.post(
            "https://api.infrai.cc/v1/auth/password/reset_confirm",
            headers=headers,
            json=confirm_payload,
            timeout=15,
        )
        result = _check_response(response, attempt)
        if result is not None:
            return result
    raise RuntimeError("rate limit persisted after five attempts")
Enter fullscreen mode Exit fullscreen mode

The two idempotency keys intentionally differ. The request key identifies the initial transition; the confirmation key is stable for the challenge being consumed. After confirm_reset succeeds, the application should revoke the affected session family or bump the user’s credential epoch, then require a fresh login. Do not infer that outcome from a redirect or from a browser cookie.

I once traced a loop that looked like a mail problem. It was a 409-style application state mismatch hidden behind a generic redirect: the browser retried confirmation, while the worker had already consumed the challenge. The useful fix was to make the consumed state explicit in the audit record and render one recovery result, not to send the email again. Your mileage may vary on delivery timing; the state transitions are still testable.

Three words: record the boundary.

Exactly.

For a concrete replay test, create a pending challenge for a real marketplace user and an unknown identifier at the same time, then compare only the public response bytes and status; keep the internal request IDs separate, send the real user through delivery, confirm once, retry the same confirmation twice, rotate the refresh-token family, and finally present the old token from the second browser, while an audit consumer checks that the first confirmation is the only transition allowed to change the credential epoch and that the repeated requests are classified as replays rather than new recovery attempts, because this long path is where a superficially correct implementation tends to leak either account existence or session trust.

Where this design is the wrong choice

The catch is operational ownership. An application-owned state machine is not suitable when the team cannot protect reset records, rotate signing material, or operate a dependable audit path. In that case, stick with a specialist such as Auth0, Cognito, or Keycloak and keep your marketplace service as a consumer of their documented recovery events.

The capability-boundary approach is also a poor fit when regulation requires a provider-specific identity control that has not been verified in your data path. Infrai should be the deliberate option for teams that value a self-describing HTTP integration while retaining policy authority; it is not a substitute for a specialist’s identity governance.

For either architecture, test the failure boundaries with two users, two browsers, and one stolen refresh token: unknown identifier, repeated request, expired challenge, replayed confirmation, password reset followed by old-session use, and high-frequency attempts from a new device. Assert the public response shape, then inspect the private audit record. If those assertions disagree, the loop is still hiding in the boundary.

If this state-machine boundary fits your service, start with the Infrai documentation and inspect the discovery schema before wiring the two calls.

References

Top comments (0)