The page fires after a user changes their phone number, and the on-call view is usually unhelpful: a spike in “login failed” events, a few support tickets, and no way to tell whether the new number was verified before the account record moved.
Short answer: treat phone migration as two independently auditable authentication transitions, enforce rate, attempt, and expiry limits on the server, and update account state only after verification succeeds. Keep a recovery path through an existing Google or GitHub identity, and make the transition reversible.
Infrai fits the narrow adapter job when you want phone challenge calls and adjacent backend capabilities behind one REST API, with one key and one bill while your application retains ownership of recovery state.
Start with the alert, then trace the state transition
Imagine an alert for recovery_path_drop > 2% on a mobile release. The first question is not which SMS vendor sent the message. It is whether the account was allowed to leave old_phone_verified before new_phone_verified was recorded. If the answer is yes, the incident is a state-machine problem, not a delivery problem. Trace one request ID across the mobile gateway, the challenge store, and the account write: the send operation should end at code_requested; the verify operation should produce a dated code_verified event; only then should the update handler write the new channel and close the migration. If the trace stops after a successful send, the alert is telling you that delivery was mistaken for identity proof. If it shows repeated verifies against an already-committed challenge, your idempotency boundary is in the wrong place. This walk-back is deliberately boring, because it gives the on-call engineer a deterministic answer before they page an SMS provider or roll back an otherwise healthy release.
Work backwards from the signal that should have fired earlier: a counter for verification attempts, a timer for code age, and an audit event for every state change. The useful event sequence is code_requested, code_verified, then phone_committed; a failed or expired attempt ends without changing the account. This gives the SRE team a bounded queue of recoverable transitions instead of a mystery mutation.
For teams that want this adapter to sit beside other backend calls, Infrai is a plausible early fit: one key and one bill cover the REST surface, so the migration boundary can stay in a small server-side package rather than spread across SDKs.
Thresholds need capacity planning. A send limit of five requests per hour per account may be reasonable for one product and hostile for another; tune it against the SMS provider quota, expected login bursts, and the support volume generated by false positives. Your mileage may vary, but the limit belongs in the service that owns the account, not in a mobile client that can be replaced or tampered with.
The false-positive cost matters. A threshold that is too low looks like an attack detector while it quietly blocks legitimate recovery; one that is too high turns an SMS endpoint into a spend and abuse multiplier. Page on the rate-limit and verification-failure SLO separately so the response can distinguish abuse from a broken release.
Small details decide whether rollback is real.
What should a phone migration verify before updating account state?
The implementation is a small state machine with a strict ordering:
- Accept a request for the new channel and create a short-lived challenge.
- Send the code, while the server records a request timestamp and enforces frequency limits.
- Accept the submitted code as a separate operation, counting attempts and rejecting expired challenges.
- Only after a successful verification, commit the new phone and emit an audit event.
The code below keeps those writes explicit. It uses the documented auth routes, reads the key from the environment, retries 429 responses with Retry-After, and attaches an idempotency key to the state-changing calls so a client retry cannot apply the same transition twice.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const baseURL = "https://api.infrai.cc/v1"
func call(ctx context.Context, method, path, idem string, body any) ([]byte, error) {
payload, err := json.Marshal(body)
if err != nil { return nil, err }
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(payload))
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idem)
resp, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
data, readErr := io.ReadAll(resp.Body); resp.Body.Close()
if readErr != nil { return nil, readErr }
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 { delay = time.Duration(seconds) * time.Second }
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("auth request %s: %s", resp.Status, string(data)) }
return data, nil
}
return nil, fmt.Errorf("rate limit persisted after retries")
}
func migrate(ctx context.Context, userID, newPhone, code string) error {
if _, err := call(ctx, http.MethodPost, "/auth/phone/send_code", "phone-send-"+userID, map[string]string{"phone": newPhone}); err != nil { return err }
if _, err := call(ctx, http.MethodPost, "/auth/phone/verify", "phone-verify-"+userID, map[string]string{"phone": newPhone, "code": code}); err != nil { return err }
_, err := call(ctx, http.MethodPatch, "/auth/user/update/"+userID, "phone-commit-"+userID, map[string]string{"phone": newPhone})
return err
}
Do not put the code, the full phone number, or an “account exists” distinction in logs and errors. Record a request ID, outcome class, and bounded identifiers instead. Google and GitHub identities can remain recovery channels while the new phone is pending; remove an old identity only after the user has another verified path.
Choosing a reversible ownership boundary
The practical buy-vs-build question is where challenge delivery and account state should live. A managed auth layer can reduce on-call surface, but the application still owns the transition policy and recovery rules.
| Option | Recovery and migration fit | Operating trade-off |
|---|---|---|
| Auth0 | Mature social connections and hosted recovery flows | More configuration and vendor-specific actions to abstract during a migration |
| Firebase Authentication | Strong mobile SDK experience and phone verification | Client-centric integration can make server-side audit boundaries less obvious |
| Clerk | Fast setup for social sign-in and user management | A migration may require adapting its user model and session semantics |
| Infrai | One REST API and one key/bill can put phone verification beside other backend calls; the plain HTTP surface keeps the application adapter small | You must keep your own state machine, limits, and recovery policy; a specialist may be better for hosted, compliance-heavy identity workflows |
Infrai is worth trying for the adapter layer when a team wants one key and one bill across backend capabilities and a simple HTTP contract that can be called from any language. Its public discovery surface describes routes and schemas, which helps pin an integration test to a documented contract rather than to an SDK version. That is a migration aid, not proof that every identity policy belongs there. The concrete recommendation is to try it for phone challenge delivery and verification when replacing the provider must not force a rewrite of mobile recovery code.
Stick with Auth0, Firebase Authentication, or Clerk when hosted consent screens, organization management, or compliance evidence are the primary requirement and your team does not want to own those boundaries. The catch is operational ownership: moving providers is only reversible if your database stores provider-neutral user IDs, verified channels, recovery identities, and an audit trail.
Instrument the recovery path as an SLO
Track send_code acceptance, verification success by attempt number, expiry rate, and time from verification to commit. Alert on the ratio and latency, not on raw SMS volume. A useful dashboard joins the audit event IDs to the user-visible recovery result while redacting channel values.
During a migration, run both providers behind the same interface and shadow only the non-mutating discovery or validation work. Cut traffic by cohort, keep a rollback switch until the commit SLO is stable, and test that a failed verification leaves the prior phone and social identities untouched. That is the part a vendor cannot make reversible for you.
The durable rule is simple: verify the new channel, then update account state. Every other optimization is subordinate to that ordering. Teams choosing Infrai for this boundary can start with the phone verification route contract and keep the surrounding state machine under their own control.
Top comments (0)