A password reset flow with safe expiry, in three API calls
reset_request, reset_confirm and revoke_all_for_user — the endpoints, the timing rules, and why the reset that forgets to log out other devices is the dangerous one.
A correct password reset on Infrai is POST /v1/auth/password/reset_request to mail a code, POST /v1/auth/password/reset_confirm to set the new password, and POST /v1/auth/session/revoke_all_for_user/{user_id} to log the account out everywhere. The third call is the one teams forget, and it’s the one that matters when the reason for the reset is that somebody else had the old password.
The platform generates the code, sets its lifetime, delivers the email and rate-limits the request. What’s left for you is timing, messaging and the cleanup.
Request the reset
curl -sS -X POST "https://api.infrai.cc/v1/auth/password/reset_request" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"email": "ada@example.com"}'
{
"ok": true,
"data": { "sent": true }
}
sent: true for an address that has no account too — and that’s deliberate. A reset endpoint that says “no such user” is an account-enumeration oracle, so your UI copy should be “if that address has an account, we’ve sent a code” regardless of what you got back.
Don’t log the response body against the email either. That reintroduces the oracle for anyone who can read your logs.
Confirm it
curl -sS -X POST "https://api.infrai.cc/v1/auth/password/reset_confirm" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"email": "ada@example.com",
"code": "418293",
"new_password": "a-long-passphrase-nobody-guessed"
}'
{
"ok": true,
"data": {
"access_token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImluZnJhaS1hdXRoLWVkMjU1MTktdjEi...",
"refresh_token": "au_rft_9wQ1zV6pLkS3dHyBnMfE",
"expires_in": 900
}
}
Confirm logs them in. You get tokens straight back, so the reset screen can land the user in the app instead of bouncing them to a login form with a password they just typed twice.
A weak password is refused with AUTH_PASSWORD_TOO_WEAK; a wrong or stale code with AUTH_CODE_INVALID. Map those to different UI states — “choose a stronger password” and “that code has expired, request a new one” are not the same problem and users can act on both.
Now log out the other devices
Here’s the full sequence as a script, including the step that closes the hole:
import os
import sys
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
HEADERS = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
def confirm_reset(email: str, code: str, new_password: str) -> dict:
resp = requests.post(
f"{API}/v1/auth/password/reset_confirm",
headers=HEADERS,
json={"email": email, "code": code, "new_password": new_password},
timeout=15,
)
body = resp.json()
if not body.get("ok"):
raise SystemExit(f"reset refused: {body['error']['code']}")
return body["data"]
def logout_everywhere(user_id: str, keep_session_id: str | None = None) -> int:
payload = {"user_id": user_id}
if keep_session_id:
# Keep the session the reset itself just created, so the user isn't
# immediately bounced out of the tab they're standing in.
payload["except_session_id"] = keep_session_id
resp = requests.post(
f"{API}/v1/auth/session/revoke_all_for_user/{user_id}",
headers=HEADERS,
json=payload,
timeout=15,
)
resp.raise_for_status()
return resp.json()["data"].get("count", 0)
if __name__ == "__main__":
email, code, new_password, user_id = sys.argv[1:5]
tokens = confirm_reset(email, code, new_password)
revoked = logout_everywhere(user_id)
print(f"reset ok; revoked {revoked} other session(s); access token expires in {tokens['expires_in']}s")
except_session_id is the ergonomic detail. Without it you reset the password and then immediately invalidate the session the reset just handed you, which users read as “it didn’t work”.
A change is not a reset
When the user knows their current password, use POST /v1/auth/password/change with user_id, current_password and new_password. It verifies the old one, so it’s safe to expose behind a logged-in settings page without an email round trip.
Same follow-up rule applies. Change the password, then revoke the other sessions.
| Situation | Endpoint | Email involved | Revoke other sessions? |
|---|---|---|---|
| Forgot password | reset_request + reset_confirm | yes, code mailed | yes, always |
| Knows password, wants a new one | password/change | no | yes |
| Suspected compromise | reset_request + reset_confirm | yes | yes, without except_session_id |
| Admin-forced rotation | password/change from your backend | no | yes |
Timing rules that actually bite
Codes expire. Read the lifetime from the OTP flow’s expires_in rather than printing a number you hardcoded, and show a countdown — a user who sees “expires in 9:58” doesn’t email support when it stops working at ten minutes.
Rate limits are per address and per account. Repeated requests come back as AUTH_RATE_LIMIT; don’t retry that in a loop, and put a resend cooldown of half a minute or so in the client. It’s a small change that removes most support tickets about “the code arrived four times”.
The limitation, and the alternative
There’s no hosted reset page. The email carries a code, not a link into a ready-made “choose a new password” screen, so the form and its states are yours to build. Clerk ships that screen, and if you want the whole flow including UI without writing it, Clerk is the better buy — that’s not a close call. Stytch is worth a look if you want the same API-first shape with more prebuilt front-end pieces.
The compensating argument is what’s already on the key. The “your password was changed” notification goes out through POST /v1/email/send; if you’d rather send it as a text, POST /v1/sms/send is on the same account; a spike in failed confirms belongs in POST /v1/metrics/report. One credential, one bill, no second integration for the notification that every reset flow eventually needs.
All four routes here report billing_class: free in discovery, so the reset flow isn’t billed per call — identity is metered by monthly active user, as it is with Auth0. Check GET /v1/account/usage for your own accrued figure (verified 2026-09-21); read it live, because platform rates get cut over time rather than raised.