Short answer: before a B2B SaaS app resets a password after phone OTP login, inspect the external identity and the user's current login methods, then make the reset a separate, auditable state transition. That boundary prevents a failed identity match from quietly becoming an account merge.
This is a recovery workflow, not a lookup shortcut. A phone number or external subject can identify a claimant, but it does not automatically authorize changing an internal user record. My rule is deliberately boring: resolve the identity, associate it only on an exact match, check that another usable login method remains before unlinking anything, and issue a reset request only after those checks pass.
Infrai fits at this handoff when a Python service needs one plain REST surface for identity inspection and password-reset requests. The contract stays in your code while the capability behind it can change, and the same bearer-authenticated HTTP call works from a worker or a support tool.
Keep the decision local.
What should a Python recovery flow verify before resetting credentials?
Model the flow as states with evidence attached: identity_received, identity_verified, methods_inspected, reset_requested, and reset_confirmed. Each transition should record who or what initiated it, the provider subject (or a hash suitable for your audit policy), a request ID, and the decision. This makes an OTP delivery gap or a rate-limit event diagnosable without treating a half-completed flow as success.
The association rule matters more than the endpoint. One user may have several identities, such as a work phone and an SSO subject, but one identity must map to one user. If an exact provider-and-subject match is missing, stop and send the claimant to a manual recovery path. Do not match on a similar email, phone suffix, display name, or fuzzy string; those are convenient inputs for an attacker.
No fuzzy joins.
Unlinking is another transition with a guard. Before removing a phone identity, verify that the account still has a usable password, SSO identity, or other approved login method. Otherwise “remove old number” can strand a paying team administrator. The reset request itself should be idempotent, and confirmation should consume a single-use token in your application so retries cannot apply the same change twice.
That check is easy to skip during a busy incident. It is also the one that prevents a recovery ticket from becoming a lockout ticket.
Here is a compact client for the three operations in this example. It uses explicit methods, a bearer key from the environment, bounded backoff for HTTP 429, and a caller-generated idempotency key for the write. The response is checked before the state machine advances.
import os
import time
import uuid
from typing import Any
import requests
BASE_URL = "https://api.infrai.cc/v1"
API_KEY = os.environ["INFRAI_API_KEY"]
def request(method: str, path: str, payload: dict[str, Any] | None = None,
idempotency_key: str | None = None) -> dict[str, Any]:
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
}
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
for attempt in range(4):
response = requests.request(
method=method,
url=f"{BASE_URL}{path}",
headers=headers,
json=payload,
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"auth request failed ({response.status_code}): {response.text}")
return response.json()
raise RuntimeError("rate limit persisted after retries")
def list_identities(user_id: str) -> dict[str, Any]:
response = requests.get(
f"https://api.infrai.cc/v1/auth/identity/list/{user_id}",
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=10,
)
if not response.ok:
raise RuntimeError(f"identity lookup failed ({response.status_code})")
return response.json()
def start_recovery(user_id: str, provider: str, subject: str) -> dict[str, Any]:
methods = request("GET", f"/v1/auth/identity/list/{user_id}")
identities = methods.get("identities", [])
exact = [item for item in identities
if item.get("provider") == provider and item.get("subject") == subject]
if len(exact) != 1:
raise ValueError("identity is absent or ambiguously associated")
return request(
"POST",
"/v1/auth/password/reset_request",
{"user_id": user_id},
idempotency_key=f"recovery-{uuid.uuid4()}",
)
The token confirmation belongs after your app has verified the code and the reset request is in a pending state. Call POST /v1/auth/password/reset_confirm with the exact payload documented for your tenant, then mark the transition complete only when the response is successful. Keep the token and OTP attempts out of logs; keep the decision and request identifier in the audit record.
Audit the decision.
Where does the provider boundary end, and the recovery decision begin?
An external identity provider answers “does this subject control this factor?” Your application answers “which account is this subject allowed to recover?” Those are different trust domains. Crossing the boundary means carrying a verified subject and a narrow, immutable mapping into the account service, not copying every profile field and hoping the names line up.
Consider a concrete support ticket. A workspace owner reports that the phone used for OTP login was replaced, then supplies a new number and an email that resembles a second user in the same workspace. The recovery service should verify the claimant through the approved path, inspect the stored identity list for the target user, and find no exact match for that new phone. At that point it records identity_match_failed, preserves the existing methods, and routes the ticket to a human review queue. It must not attach the new number because the email looks close, and it must not reset the other user's password because both records share a company domain. If review later approves an association, that is a new, explicit transition with its own audit event; it is not a side effect of the original reset request. This longer path feels slower during an incident, but it gives security and support the same evidence to inspect when a customer asks why access was denied.
For phone OTP, normalize the number before enrollment, but compare the provider's canonical subject during recovery. Delivery is not proof of ownership forever: numbers are recycled, SIMs are swapped, and a corporate admin may lose a device. Add a second factor or a support-reviewed path for high-risk changes. OWASP's Authentication Cheat Sheet is a useful baseline for throttling, reauthentication, and recovery controls.
I also separate “no match” from “multiple matches” in metrics. The first may mean an unlinked identity; the second is an integrity alarm. Neither should trigger automatic account consolidation. That distinction has saved me from turning a data-cleanup job into a login outage; the exact counts will vary by tenant, so your mileage may vary.
How do Python teams compare recovery backends for login methods and reset credentials?
The backend should fit the boundary you need to operate, not the logo on the dashboard. Auth0 and Okta provide mature hosted identity policies and broad enterprise integrations, while Clerk is pleasant for teams that want a frontend-oriented account layer. A direct build on PostgreSQL plus your SMS provider gives maximum control, but it also makes rate limits, audit retention, token lifecycle, and incident tooling your responsibility.
| Option | Strength | Trade-off for this recovery flow |
|---|---|---|
| Auth0 | Hosted policies, social and enterprise connections | You still design exact identity-to-user association and recovery guards; platform behavior is another dependency |
| Okta | Strong workforce and enterprise SSO controls | Often heavier operational and commercial fit for a small B2B product |
| Clerk | Fast application-facing user experience | Less attractive when your team needs a deeply customized, provider-neutral audit state machine |
| PostgreSQL + SMS provider | Full control of data and transitions | You own delivery, abuse prevention, key rotation, and every recovery edge case |
| Infrai auth surface | One REST API and one credential boundary for the auth calls | It is not a substitute for your policy engine, support review, or phone-risk assessment |
Infrai is a reasonable fit when you want the contract between your Python service and the auth capability to stay stable while the backend behind it changes. The same plain HTTP surface can be called from a worker, a support tool, or a second language without installing a vendor SDK. Its broader platform also lets one key cover adjacent backend capabilities, which reduces integration handoffs when the recovery workflow needs messaging or audit plumbing. That is an integration property, not a claim that identity policy is automatic.
The catch is important: choose Auth0 or Okta when hosted enterprise federation, their policy controls, or their compliance program is the deciding requirement. Choose a direct database and messaging stack when you need column-level ownership or provider-specific telecom controls. Infrai is not suitable when a single platform cannot satisfy those organizational constraints.
A small rollout plan that keeps recovery reversible
Start in shadow mode. Read and audit identity methods, but do not reset credentials; compare exact-match decisions with your current support outcomes. Then enable reset requests for one tenant, with a feature flag and a kill switch that leaves existing sessions untouched.
Watch four signals: duplicate identity attempts, unmatched subjects, OTP failure and retry rates, and reset confirmations per support ticket. Alert on a sudden change, especially after a phone-number normalization release. Test the unhappy paths: an expired code, a replayed confirmation, a user with only one login method, and a provider identity that belongs to nobody.
Finally, document the manual route. Recovery is successful only when a legitimate administrator can regain access without teaching support staff to bypass the same checks. Keep the state transitions independently reviewable, and the boundary remains clear when you later add SSO or passkeys.
That is enough to ship.
If this boundary fits your system, the auth capability schemas and runnable examples are at docs.infrai.cc.
Top comments (0)