Changing a user's email address without losing the account
change_request mails a token to the new address, change_confirm commits it. The ordering rules that stop a typo locking someone out of their own account.
Changing an email address is the login-flow operation most likely to lock a real user out of a real account, because the address usually is the identity. Infrai splits it in two so a typo can’t be fatal: POST /v1/auth/email/change_request records the intent and mails a token to the new address, and POST /v1/auth/email/change_confirm commits the change only when that token comes back.
Until the token is redeemed, the old address still works. That ordering is the whole safety property.
Request the change
curl -sS -X POST "https://api.infrai.cc/v1/auth/email/change_request" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"user_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
"new_email": "ada.lovelace@example.com"
}'
{
"ok": true,
"data": { "ok": true, "pending_token": "au_ect_4kQ9mVzR1sXbNt" }
}
The confirmation goes to the new address, which is the only way to prove the user can actually receive mail there. If they fat-fingered the domain, nothing happens — the token is never redeemed and the account keeps working.
If the new address already belongs to another user you’ll get AUTH_USER_EXISTS. Surface that as “that address is already in use” rather than swallowing it, and do not offer to merge the accounts automatically.
Confirm it
curl -sS -X POST "https://api.infrai.cc/v1/auth/email/change_confirm" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"token": "au_ect_4kQ9mVzR1sXbNt"}'
The response is the full updated user record — user_id, the new email, email_verified, name, mfa_enabled, created_at, metadata. One call, and the change is live.
Note what confirm does not take.
No user_id, no old address — the token carries all of it, which means the confirm endpoint is safe to expose behind a link-handling route without the caller needing to know whose account it is, and it also means the token is the whole secret, so treat a leaked confirmation email with the same seriousness you’d treat a leaked password reset link and keep the redemption window short.
The full flow, with the notification that matters
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
HEADERS = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
def request_change(user_id: str, new_email: str) -> str:
resp = requests.post(
f"{API}/v1/auth/email/change_request",
headers=HEADERS,
json={"user_id": user_id, "new_email": new_email.strip().lower()},
timeout=15,
)
body = resp.json()
if not body.get("ok"):
raise RuntimeError(body["error"]["code"])
return body["data"]["pending_token"]
def notify_old_address(old_email: str, new_email: str) -> None:
"""Tell the OLD address that a change was requested. This is the step that
turns a silent takeover into an alert the real owner can act on."""
requests.post(
f"{API}/v1/email/send",
headers=HEADERS,
json={
"to": old_email,
"subject": "Someone asked to change the email on your account",
"html": f"<p>A change to {new_email} was requested. If this wasn't you, "
f"reply immediately — the change is not active yet.</p>",
"message_class": "transactional",
},
timeout=20,
)
def confirm_change(token: str) -> dict:
resp = requests.post(
f"{API}/v1/auth/email/change_confirm",
headers=HEADERS,
json={"token": token},
timeout=15,
)
resp.raise_for_status()
return resp.json()["data"]
if __name__ == "__main__":
user_id, old_email, new_email = os.environ["USER_ID"], os.environ["OLD_EMAIL"], os.environ["NEW_EMAIL"]
token = request_change(user_id, new_email)
notify_old_address(old_email, new_email)
print(f"pending token issued; confirm with it once the user clicks through: {token[:12]}…")
That middle function is not optional in any product holding something of value. An attacker with a live session who changes the address quietly owns the account the moment the change lands; an email to the old address is how the real owner finds out in time.
Ordering rules worth enforcing
| Rule | Why |
|---|---|
| Confirm before switching | a typo must not strand the user |
| Notify the old address on request | detects session hijack in progress |
| Re-authenticate before requesting | a stale session shouldn’t be able to change identity |
| Revoke other sessions after confirm | the change is a good moment to clear old devices |
| Don’t reuse the pending token | one change, one token |
Re-authentication deserves a sentence of its own. Requiring the password again — POST /v1/auth/session/create with the current credentials — before you accept a change request means an attacker needs more than a stolen cookie, and it’s about four lines of code.
Keeping your own record straight
Your own tables almost certainly store the email too, for invoices, support search or a CRM sync. Update those from the confirm response rather than from what the user typed, so the value you keep is the one the platform actually committed. GET /v1/auth/user/get/{user_id} is the read to reconcile against later, and PATCH /v1/auth/user/update/{user_id} is where a metadata.email_changed_at audit stamp belongs.
Watch out for one thing: metadata replaces wholesale on update, so read before you write or you’ll drop the keys another feature put there.
Limitations
There’s no hosted “confirm your new address” page — the token arrives and the landing route is yours to build, along with its expired-token state. Clerk ships that page and the account-settings screen around it, so if you want the whole email-change experience without writing a form, Clerk is the better buy. Stytch similarly leans further into prebuilt flows.
And there’s no automatic account merge if the new address already exists, which is the honest limit on how clever this endpoint can be.
The part that’s already handled is the notification half. Both the alert to the old address and the confirmation to the new one go through POST /v1/email/send on the same key that made the change — no second vendor, no second key to rotate, one line in GET /v1/account/usage. If you’d rather also text the user, POST /v1/sms/send is on the same account.
Both auth routes here report billing_class: free in discovery, so the change flow isn’t billed per call; identity is metered per monthly active user, the way Auth0 and Clerk meter it. The emails are the billable part — read the live rate from GET /v1/discovery and your actual spend from GET /v1/account/usage (verified 2026-09-21), and expect those numbers to fall rather than rise.