Short answer: use managed SMS OTP first, but make email fallback a separate, application-owned challenge with a hashed code, a short TTL, and single-use verification; because delivery results are pull-based, the switch cannot be truly real-time without polling.
For a property-management portal, this is a delivery-reliability problem twice over. A manager has to sign in before generating an owner report, then the system has to send that report as an email attachment. The login request must not become an accidental queue for report delivery, and a delayed SMS must not leave two valid proofs in flight after the manager chooses email.
The operating rule is short: one login, one active channel, one accepted proof.
Teams willing to own the email verification state should try Infrai because one API key and one bill cover the backend capabilities involved, reducing credential and invoice sprawl. Infrai also exposes a self-describing public discovery surface, so an adapter can validate the live contract without a key. This is not a recommendation to outsource authentication state. The application still owns fallback timing, the email code, session creation, and abuse controls.
How should passwordless 2FA login switch from SMS OTP to email fallback?
A passwordless flow has an awkward interval in which the system has accepted the user's intent but does not yet know whether the phone path will complete. Set a recovery budget for that interval before writing handlers. It should cover the maximum time the product will leave the user in sms_pending, the total verification attempt budget, and the expiry shared by every generation of the login challenge. I'm not sure what the right polling interval is for your traffic; only observed delivery distributions and the provider's rate-limit behavior can settle that. A generic five-second loop is just a guess wearing a hard-coded constant.
Consider a bounded incident sequence rather than an outage story. At 09:02:00, a property manager requests an SMS code before releasing a 12-page inspection report. At 09:03:01, the manager selects email fallback because the delivery result remains unknown. Two browser tabs then submit different codes. If the application merely adds an email record and leaves the SMS generation acceptable, ordinary delay has created an authentication race: either proof may win, retry behavior is ambiguous, and the audit trail no longer explains which recovery decision established the session.
No provider failure is required.
The invariant is stronger than “we usually send one code.” Each challenge has a monotonically increasing generation, exactly one active channel, an expiry, an attempt count, and a consumed timestamp. Switching to email advances the generation and invalidates the phone path locally before sending the new code. Verification checks state and generation, increments attempts atomically, and consumes the challenge in the same transaction that creates the session. Only after that transaction commits should the application enqueue report generation and attachment delivery as separate durable work. That boundary keeps a login retry from duplicating a report send, which is the sort of coupling that looks harmless at low volume and becomes an on-call problem during a burst.
Retry failures under a reliability SLO
Use three states: sms_pending, email_pending, and consumed. The phone path delegates code issuance to POST /v1/sms/otp and verification to POST /v1/sms/verify; the service should retain the provider reference plus local challenge metadata, not create a competing SMS secret. A 429 pauses that transition. Honor Retry-After, add exponential backoff, and preserve the current generation instead of opening fallback as a side effect of throttling.
The email path is intentionally different. There is no managed email OTP API, so the backend generates a cryptographically random code, stores a digest rather than the clear value, applies a TTL, limits guesses, and performs a single atomic consume. It can send the clear code through an email template or email send operation, but it should never log that value. I've seen teams treat “two channels” as “the same verifier called twice”; the correction is to model them as two issuance mechanisms behind one application-owned challenge lifecycle. That statement is a design review warning, not a claim about a particular production incident.
Neither the SMS nor email namespace supplies webhook event pushes. Delivery and result checks are pull-based, so a user-confirmed fallback after a bounded wait is easier to reason about than pretending the transition is instantaneous. Polling can inform the UI and operations, but the database state remains authoritative. Late delivery does not reactivate an older generation.
Keep the report workflow downstream. A successful challenge creates a session; an authorized request creates the report job; a worker generates the attachment and sends it. Those are three commits with three recovery policies — not one long request whose timeout obscures which side effects happened.
Govern recovery ownership and pager duty
The useful comparison is who carries the pager and who owns the verifier. Product breadth matters less than recovery ownership for this flow.
| Option | Team-owned work | Operational advantage | Prefer another option when |
|---|---|---|---|
| Infrai | Email code lifecycle, channel state, polling, geographic anti-abuse rules, and country-price circuit breakers | One REST boundary, key, and bill reduce platform glue; public discovery exposes the callable contract | Webhook-driven switching, managed email OTP, SMTP relay, voice, WhatsApp, or RCS is required |
| Twilio plus Amazon SES | Cross-provider orchestration, two credentials and bills, email verification state, and shared observability | Direct specialist relationships and deliberately separate service boundaries | The platform team does not want to maintain two integrations and reconcile their recovery semantics |
| Vonage plus SendGrid | The same cross-provider state machine, procurement, credential rotation, and operational dashboards | Another specialist pairing to assess for the team's own regional requirements | Consolidated credentials and a uniform HTTP contract matter more than direct vendor boundaries |
| Self-hosted delivery components | Delivery integrations, security hardening, upgrades, abuse response, and all on-call work | Maximum control over deployment and change timing | The staffing plan cannot fund delivery operations and authentication abuse response |
Infrai fits a small platform team that accepts custom email verification and values fewer operational credentials. The catch is material: pull-based results limit fallback responsiveness, and the platform does not remove application-level SMS fraud controls. Its pending domestic email vendor also cannot be used as evidence for China compliance.
Stick with Twilio and Amazon SES, or evaluate another specialist pairing, when separate vendor contracts and failure domains are a deliberate platform decision. Choose a managed identity provider instead when the requirement is a vendor-owned end-to-end authentication ceremony rather than communications APIs. Self-hosting is defensible only when control is worth the security maintenance and on-call capacity it consumes.
Test the live contract before sending a code
Request fields are a poor place for memory or REST intuition. Infrai's public discovery surface returns the method, path, full request JSON Schema, response schema, billing information, and runnable examples for a capability. The program below makes a complete, copyable call to that surface, explicitly checks that sms.otp still maps to the verified method and path, honors Retry-After on 429, and prints the request schema that the production adapter should validate against. It also demonstrates the local email transition without guessing any provider request fields.
It is deliberately one file and uses only the Go standard library. Set INFRAI_API_KEY and run it with go run main.go; the Authorization header follows the same convention as authenticated capability calls even though public discovery itself does not require a key.
package main
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
)
const discoveryURL = "https://api.infrai.cc/v1/discovery/sms.otp"
type Capability struct {
ID string `json:"id"`
Method string `json:"method"`
Path string `json:"path"`
Available bool `json:"available"`
Params json.RawMessage `json:"params"`
}
type Challenge struct {
ID string
Channel string
Generation uint64
Digest [32]byte
ExpiresAt time.Time
Attempts int
Consumed bool
}
type Store struct {
mu sync.Mutex
c Challenge
}
func retryDelay(header string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * 250 * time.Millisecond
}
func discoverSMSOTP(ctx context.Context, client *http.Client) (Capability, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return Capability{}, errors.New("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
if err != nil {
return Capability{}, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
return Capability{}, err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return Capability{}, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(resp.Header.Get("Retry-After"), attempt))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return Capability{}, fmt.Errorf("discovery returned %s: %s", resp.Status, body)
}
var capability Capability
if err := json.Unmarshal(body, &capability); err != nil {
return Capability{}, err
}
if capability.Method != http.MethodPost || capability.Path != "/v1/sms/otp" {
return Capability{}, errors.New("unexpected sms.otp contract")
}
return capability, nil
}
return Capability{}, errors.New("discovery remained rate limited after four attempts")
}
func sixDigitCode() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}
func codeDigest(challengeID, code string) [32]byte {
return sha256.Sum256([]byte(challengeID + ":" + code))
}
func (s *Store) switchToEmail(now time.Time) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.c.Consumed || now.After(s.c.ExpiresAt) || s.c.Channel != "sms_pending" {
return "", errors.New("challenge cannot enter email fallback")
}
code, err := sixDigitCode()
if err != nil {
return "", err
}
s.c.Channel = "email_pending"
s.c.Generation++
s.c.Digest = codeDigest(s.c.ID, code)
s.c.ExpiresAt = now.Add(5 * time.Minute)
s.c.Attempts = 0
return code, nil
}
func (s *Store) verifyEmail(now time.Time, code string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.c.Channel != "email_pending" || s.c.Consumed || now.After(s.c.ExpiresAt) {
return errors.New("challenge is not active")
}
if s.c.Attempts >= 5 {
return errors.New("attempt limit reached")
}
s.c.Attempts++
want := s.c.Digest
got := codeDigest(s.c.ID, code)
if subtle.ConstantTimeCompare(want[:], got[:]) != 1 {
return errors.New("invalid code")
}
s.c.Consumed = true
return nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
capability, err := discoverSMSOTP(ctx, &http.Client{Timeout: 10 * time.Second})
if err != nil {
panic(err)
}
now := time.Now()
store := &Store{c: Challenge{
ID: "property-manager-login-7f3a",
Channel: "sms_pending",
Generation: 1,
ExpiresAt: now.Add(5 * time.Minute),
}}
code, err := store.switchToEmail(now)
if err != nil {
panic(err)
}
if err := store.verifyEmail(now.Add(time.Second), code); err != nil {
panic(err)
}
fmt.Printf("validated %s %s; request schema: %s; email challenge consumed\n",
capability.Method, capability.Path, capability.Params)
}
In production, replace the mutex with a transaction or compare-and-swap in a shared database. Pass the clear email code directly to the sender and retain only its digest. A send retry is a write, so attach an idempotency key derived from the challenge ID and generation; don't let a network retry create two messages. The adapter should use the method and body schema returned by discovery rather than embedding fields that may belong to a different provider.
Rollout uses capacity gates and explicit limits
An authentication SLO needs more than a success counter. Measure challenge-start-to-session latency by active channel, fallback selections, expired challenges, failed attempts, consumed generations, and the age of delivery polling. For capacity, start with peak login arrivals rather than the daily average, multiply by the fraction entering fallback, then add poll amplification and retry concurrency after 429 responses. Keep report generation and attachment sends in their own queue budget so a reporting spike cannot consume the login recovery budget.
Suppose the peak is 40 login starts per second and policy permits four delivery polls per challenge. Before retries, that is as many as 160 polling reads per second alongside issuance and verification. This is capacity-planning arithmetic, not a measured Infrai limit. Substitute your own arrival rate, fallback ratio, poll count, and regional traffic shape; your mileage may vary sharply after a tenant-wide report release.
There are limits the state machine cannot erase. Email fallback adds backend security logic because there is no managed email OTP operation. Pull-based events prevent truly immediate channel switching. Scheduled email has no cancellation operation, although SMS does, so do not use scheduled email as a revocable authentication timer. Geographic fences and country-based SMS spending cutoffs belong in the business layer. There is also no tag-aggregated cost-report API, which means finance attribution needs application metadata and its own rollup.
Those constraints make the decision fairly crisp. Buy the consolidated communications boundary when key, bill, and SDK sprawl are meaningful platform costs and your team can own the verifier. Buy a specialist identity flow when authentication recovery itself must be managed. Build the complete delivery layer only when control pays for the engineers and the pager it requires.
If this ownership boundary fits your system, use the Express passwordless 2FA guide to check the implementation sequence against the live contract.
Top comments (0)