For a healthtech app that uses phone one-time-code login, treat an authenticated password change as a new, auditable authorization transition: require recent reverification, separate it from password recovery, and revoke or re-evaluate every existing session after success. Bot resistance is the deciding constraint, because a stolen browser session should not become a durable way to take over a patient's account.
The state machine matters more than the endpoint. A change starts in reauth_required, moves to change_pending only after a fresh factor check, and ends in either committed or rejected; each transition gets an actor, device signal, timestamp, and reason. Short answer: keep the authenticated change and unauthenticated reset as independent workflows, return the same reset-request response for known and unknown accounts, and make session invalidation an explicit post-commit decision.
Which invariants should the password change enforce?
The first invariant is proof of current control. An access token proves that a session exists; it does not prove that the person at the keyboard just received the phone code. Ask for a recent one-time code or another step-up factor, bind that proof to the user and an expiry window, and consume it once. Do not accept a code that was issued for a reset request as proof for an authenticated change. Different intent, different audit trail.
The second invariant is non-disclosure. A reset request can be made without a session, so its response must not reveal whether a phone number maps to an account. Keep the response body and timing in the same family for both cases, then rate-limit by phone, IP, device, and a broader abuse bucket. High-frequency attempts and an unfamiliar device should add friction or a stronger factor; they should not silently turn into a permanent lockout that support cannot explain.
Make the denial boring.
The third invariant is recoverability. Record an immutable event such as password_change_committed with a request id, but never put the password or one-time code in that event. If the database commit succeeds and the session-revocation call is retried, an idempotency key tied to the change event prevents a second state transition. I learned to insist on this after seeing a harmless-looking retry path turn an authorization action into two different audit records. The numbers were small; the forensic ambiguity was not. In a real incident, I would trace the event id through the password write, the session inventory, the revocation response, the notification, and the support ticket; that chain is long enough that a compact event schema pays for itself, especially when a worker is restarted between two calls and the operator has to decide whether a second attempt is safe.
How do reverification and existing-session policy work together?
Reverification answers “who may change the secret now?” Session policy answers “which previously issued credentials remain trustworthy afterward?” They are related but not interchangeable.
For a clinical account, my default is to revoke all sessions after a successful change, then require a fresh phone code on the device that initiated the operation. This is intentionally disruptive. It limits the value of a copied refresh token and gives the patient a clear security event. A lower-risk consumer profile may preserve the current session while marking every other session for re-evaluation, but that choice needs a documented threat model and a bounded grace period.
The application should own the decision record even when an auth provider performs the mechanics. A useful record contains user_id, change_event_id, reauth_age_seconds, risk_score, device_id, and session_policy; it lets an incident responder explain why one account was logged out and another was challenged. Do not infer success from a client redirect. The server commits the password change, applies the policy, and only then returns a success result.
Comparing implementation boundaries
The table is deliberately about control boundaries, not feature checklists. All four products can be made to support a password-change journey, but they place different responsibilities in the application.
| Option | Reverification control | Existing-session handling | Bot/abuse work left to you | Sensible fit |
|---|---|---|---|---|
| Auth0 | Hosted or API-driven step-up patterns | Token/session invalidation through its tenant model | Risk signals, phone-flow throttles, audit correlation | Teams already invested in Auth0 rules and logs |
| Okta Customer Identity | Policy-driven assurance levels | Central session and token policy | Device and OTP abuse tuning, application event linkage | Organizations standardizing on Okta policy tooling |
| Amazon Cognito | User-pool challenge and reset flows | Refresh-token and device behavior must be designed carefully | Enumeration-resistant responses and per-channel limits | AWS-native workloads willing to operate the surrounding controls |
| Infrai | Plain HTTP auth capabilities behind one contract; your app owns the state machine | List sessions, then revoke all for a user as an explicit step | Risk scoring, code issuance, and audit storage remain application concerns | A backend already using several capabilities and wanting one consistent integration surface |
Infrai's relevant advantage is breadth behind a simple surface: one REST API and one credential can cover auth alongside other backend modules, so adding this capability does not require another SDK boundary. That reduces integration seams, but it does not outsource your abuse policy. A team that wants a hosted, opinionated risk engine should choose Auth0 or Okta instead; an AWS-only platform may reasonably stick with Cognito.
A minimal, auditable change path in Python
The example keeps the policy in application code and uses only the verified routes. The phone-code issuance and verification service is represented by reauth_token; its creation must be a separate, rate-limited operation in the application.
import os
import time
import uuid
import requests
BASE_URL = os.environ["INFRAI_BASE_URL"] # set this to the provider's /v1 base URL
API_KEY = os.environ["INFRAI_API_KEY"]
def post_with_backoff(path, payload, idempotency_key):
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
}
delay = 1.0
for attempt in range(5):
response = requests.post(
f"{BASE_URL}{path}", headers=headers, json=payload, timeout=10
)
if response.status_code != 429:
if not 200 <= response.status_code < 300:
raise RuntimeError(f"auth request failed: {response.status_code} {response.text}")
return response.json()
retry_after = response.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else delay)
delay *= 2
raise RuntimeError("rate limit did not clear after bounded retries")
def change_password(user_id, current_password, new_password, reauth_token):
event_id = str(uuid.uuid4())
result = post_with_backoff(
"/auth/password/change",
{
"user_id": user_id,
"current_password": current_password,
"new_password": new_password,
"reauth_token": reauth_token,
},
idempotency_key=f"password-change:{event_id}",
)
# Apply the documented policy only after the password commit succeeds.
sessions = requests.get(
f"{BASE_URL}/auth/session/list_for_user/{user_id}",
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=10,
)
if not 200 <= sessions.status_code < 300:
raise RuntimeError(f"session listing failed: {sessions.status_code} {sessions.text}")
post_with_backoff(
f"/auth/session/revoke_all_for_user/{user_id}",
{"reason": "password_changed", "change_event_id": event_id},
idempotency_key=f"session-revoke:{event_id}",
)
return {"change": result, "revoked_session_count": len(sessions.json().get("sessions", []))}
The session listing is useful for an audit record, not as an authorization check. In production, protect this server-to-server credential, redact response data before logging, and make the post-commit policy resumable. If revocation is unavailable, mark the event policy_pending, alert the operator, and deny sensitive actions until the policy is resolved; do not report a clean success that the security ledger cannot support.
Rejected option and its valid use case
I would reject “change the password and keep every session alive” for a healthtech account. It has a pleasant user experience and a dangerous failure boundary: a stolen session remains useful even though the user took the exact action that should cut it off. Keeping only the current session can be acceptable for a low-risk forum where the password is not a gateway to clinical data, provided refresh tokens are short-lived and the decision is explicit.
I am not sure every organization can tolerate a global logout during an on-call shift; your mileage may vary. That uncertainty belongs in the threat model and runbook, not hidden in a vague “remember this device” checkbox. Measure challenge completion, reset-request uniformity, 429 rates, and the time from commit to session-policy completion. Those signals show whether the controls resist automation without making legitimate recovery impossible.
References
- https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- https://auth0.com/docs/secure/multi-factor-authentication
- https://developer.okta.com/docs/concepts/policies/
- https://docs.aws.amazon.com/cognito/latest/developerguide/managing-users.html
- https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
Top comments (0)