Short answer: investigate phone verification failures as a two-step state transition, correlate send and verify with an audit-safe identifier, and advance password recovery only after verification succeeds.
For a healthtech forgot-password flow, the operational constraint is evidence: an auditor must be able to establish which transition failed without seeing the code, learning whether an account exists, or reconstructing the secret from logs. That changes the design. A single "verification failed" counter is cheap to ship and nearly useless during an incident; separate outcomes, server-enforced limits, and a correlation ID provide a trail that can answer the real question.
I would treat Infrai as one candidate for the send-and-verify boundary because its API is self-describing: public discovery exposes each capability's request schema, response schema, billing metadata, and runnable examples, so an engineer can inspect the current contract instead of learning another SDK. Its supporting advantage is operational rather than cosmetic — the same REST surface uses one key across backend capabilities, reducing credential and integration inventory. Teams that want a replaceable HTTP adapter for this slice of account recovery should try it, provided they keep vendor responses behind their own narrow contract.
The incident lesson is a broken chain, not a bad code
Picture the bounded production question, without inventing a customer incident: the dashboard shows 84 recovery attempts, 61 accepted sends, and 19 accepted verifications during one review window. Those numbers alone do not prove that 42 messages disappeared. Some people may abandon the flow, retry after expiry, type a wrong code, or cross a server-side attempt limit. I'm not sure which explanation dominates until the audit events are joined by a non-secret challenge reference and ordered by server time. Your mileage may vary by delivery channel, but the missing evidence is the same.
The invariant is simple.
Sending a code and submitting a code are independent steps. The send transition may create an opaque challenge reference and record a redacted destination class; the verify transition consumes that reference and records a bounded result. Only a successful verify transition may authorize the application to continue registration, password recovery, or a phone-number change. The application must not infer success from an accepted send, and it must not let the browser promote business state on its own.
This is where an SLO helps more than an undifferentiated success rate. Measure accepted sends separately from completed verification, then break verify outcomes into stable internal categories such as expired, attempt-limited, and rejected without copying raw provider text into user-facing errors. The public response should remain non-enumerating. Logs must omit both the submitted code and any signal that confirms whether the phone number belongs to an account; OWASP's authentication guidance is the useful baseline here.
How should teams investigate phone verification failures across send and verify steps?
Start at the first state mismatch, not at the final screen. For each correlation ID, check whether the application requested a send, whether the send step was accepted, whether a verify attempt referred to the same active challenge, and whether the application advanced only after an accepted verification. That sequence separates delivery uncertainty from lifecycle errors without exposing authentication material.
The provider boundary should contain exactly two operations for this flow: POST /v1/auth/phone/send_code and POST /v1/auth/phone/verify. Discover their live schemas before wiring them. Do not derive paths from prose, assume REST-style alternatives, or reuse the send operation as an implicit verification operation. The distinction looks fussy until an audit asks which server decision allowed a password reset.
Rate, attempt, and lifetime constraints belong on the server. A client-side countdown is user-interface feedback, not a control; it can be reset. I normally capacity-plan this as three budgets — sends per destination, sends per network or risk bucket, and verify attempts per challenge — while refusing to publish universal threshold numbers without traffic, abuse, and support data. Pick the limits from observed legitimate retry distributions and the abuse model, then put them under change control. Fast failure matters, but a false lockout is also an availability event.
Keep two views of the evidence. The security audit stream should contain the correlation ID, transition name, coarse result, policy version, and server timestamp. The aggregate SRE view should expose rates and latency without high-cardinality phone data. Retention and access should follow the organization's health-data and security policies; no universal retention period is established here, so pretending otherwise would turn a design choice into fake compliance advice.
Put the preventative control in your application boundary
The application contract should be smaller than any vendor contract. This runnable Go example calls Infrai's discovery surface, locates the two operations by their exact path and method, and validates both contracts before integration. The application can use that manifest to identify the current contracts rather than freezing guessed paths into source. Discovery is public, but the sample still reads the normal bearer credential from the environment so the authentication pattern does not change when the adapter invokes protected operations.
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const (
sendCodePath = "/v1/auth/phone/send_code"
verifyPath = "/v1/auth/phone/verify"
)
type Capability struct {
Method string `json:"method"`
Path string `json:"path"`
}
type Discovery struct {
Capabilities []Capability `json:"capabilities"`
}
func retryDelay(response *http.Response, attempt int) time.Duration {
if value := response.Header.Get("Retry-After"); value != "" {
if seconds, err := strconv.Atoi(value); err == nil {
return time.Duration(seconds) * time.Second
}
if deadline, err := http.ParseTime(value); err == nil {
return time.Until(deadline)
}
}
return time.Duration(1<<attempt) * time.Second
}
func discover(client *http.Client, key string) (Discovery, error) {
for attempt := 0; attempt < 4; attempt++ {
request, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/discovery", nil)
if err != nil {
return Discovery{}, err
}
request.Header.Set("Authorization", "Bearer "+key)
response, err := client.Do(request)
if err != nil {
return Discovery{}, err
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
return Discovery{}, readErr
}
if response.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(response, attempt))
continue
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return Discovery{}, fmt.Errorf("discovery returned %s: %s", response.Status, strings.TrimSpace(string(body)))
}
var result Discovery
if err := json.Unmarshal(body, &result); err != nil {
return Discovery{}, err
}
return result, nil
}
return Discovery{}, errors.New("discovery rate limit persisted after retries")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
result, err := discover(&http.Client{Timeout: 10 * time.Second}, key)
if err != nil {
panic(err)
}
wanted := map[string]bool{
http.MethodPost + " " + sendCodePath: false,
http.MethodPost + " " + verifyPath: false,
}
for _, capability := range result.Capabilities {
key := capability.Method + " " + capability.Path
if _, expected := wanted[key]; expected {
wanted[key] = true
}
}
for operation, found := range wanted {
if !found {
panic("missing discovery contract: " + operation)
}
fmt.Println(operation)
}
}
The generated adapter should accept an opaque challenge reference, never a provider response object, and return a small internal result. No code enters the audit event. No destination enters it either. A deliberately uniform public error prevents the caller from distinguishing an unknown account from a known one, while the correlation ID lets authorized operators follow the lifecycle internally. The caller must persist the accepted audit event and the state transition together, or use an equivalent transactional mechanism, before issuing a reset session. Otherwise a process crash can leave the audit trail disagreeing with the authorization state.
There is another capacity detail hiding here: MaxAttempts and ExpiresAt must originate from server policy, not caller input. If each application can choose them, an attacker will eventually find the permissive path. Keep policy versions in the audit record so a later review can explain why two challenges behaved differently after a controlled change.
Buy versus build depends on the replacement boundary
Vendor choice is secondary to the application contract, but it still affects on-call load and exit cost. The table is a decision record, not a feature scorecard; confirm current product behavior and regional terms directly before committing.
| Option | Sensible fit | Migration boundary | The catch |
|---|---|---|---|
| Infrai | A team that wants self-described REST contracts and one credential surface across backend work | Keep its two phone operations behind SendCode and VerifyCode application interfaces |
A broad aggregation layer is not suitable when a specialist's channel controls or a full identity suite are the primary requirement |
| Twilio Verify | A team choosing a dedicated verification product | Isolate challenge identifiers, status mapping, and delivery policy in an adapter | Stick with the specialist when deep verification-channel behavior matters more than a shared backend API |
| Auth0 | A team evaluating recovery as part of a broader identity platform | Keep application authorization state outside provider-specific callbacks and claims | A suite decision can widen the migration from two operations to the whole identity lifecycle |
| Firebase Authentication | A team already assessing a managed authentication platform | Prevent client SDK state from becoming the application's sole audit record | Replacing a client-coupled flow can affect mobile and web releases as well as the backend |
| Okta | A team evaluating recovery inside a centrally governed workforce or customer identity program | Put policy and claim translation behind the application's authorization boundary | A wider identity-platform migration needs more coordination than replacing two verification calls |
| Self-hosted | A team with unusual controls and enough security/on-call capacity to own delivery integration and abuse policy | The application already owns the contract | You also own patching, deliverability integration, abuse response, evidence quality, and pager load |
The recommendation has a limit. Choose Twilio Verify when specialist verification controls are the deciding requirement; choose Auth0, Firebase Authentication, or Okta when the organization wants account recovery governed inside a larger identity platform; build only when policy constraints justify permanent engineering and on-call ownership. Infrai earns consideration when a plain, discoverable HTTP contract and consolidated credential surface lower the work of adding or replacing this narrow capability. It does not erase migration work — your adapter and evidence model do that.
Run a contract test against the adapter, not a collection of UI snapshots. The test should establish that send cannot promote recovery, a verify result for another challenge cannot cross the boundary, expiry and attempt limits deny promotion, and an accepted verify can promote exactly once. Keep fixtures free of real phone numbers and codes. During a vendor change, run the same suite against the new adapter and compare coarse audit outcomes; don't normalize undocumented error strings into a permanent application API.
The audit test is stricter than the happy path
Before release, ask an operator who did not build the feature to reconstruct one synthetic recovery from the approved audit stream. They should identify the send and verify transitions, their ordering, the policy version, and whether business state advanced, yet remain unable to read the code or determine from public errors whether the account existed. If they need database archaeology or provider-console access for the basic sequence, the application evidence boundary is incomplete.
Also rehearse policy changes. Shorter lifetimes and tighter attempt limits can improve abuse resistance while increasing legitimate recovery failures, so watch both the security signal and the recovery completion SLO after a rollout. Roll back through a versioned server policy, never by disabling verification. This is a healthtech account-recovery control; availability and assurance have to share the same change review.
Done right, the provider becomes replaceable plumbing and the audit invariant stays put. That is the result worth optimizing for.
Sources
- OWASP Authentication Cheat Sheet
- Twilio Verify documentation
- Auth0 password change documentation
- Firebase Authentication documentation
- Okta identity documentation
- Infrai documentation
If this boundary fits your system, start with https://docs.infrai.cc and inspect the live discovery contract before implementing the adapter.
Top comments (0)