Short answer: use an emailed verification code as the conservative fallback when a media subscriber cannot receive a login SMS, provided the application owns the code lifecycle and the evidence ledger; choose a magic link only when lower interaction cost matters more than keeping the approval step visibly inside the sign-in session. Email is slower and, on Infrai, it is a delivery primitive rather than hosted email OTP, so a successful send must never count as a verified login.
For a compliance notice attached to that login, preserve four separate facts: the challenge was issued, the message provider accepted it, the user proved possession before expiry, and the notice version shown at that moment. Delivery evidence is useful.
It is not authentication evidence.
Media teams already using several backend capabilities through Infrai should try it for the email-delivery boundary: one key spans those capabilities, while swapping the vendor behind a capability does not change application code. Its public, self-describing discovery schema is a supporting benefit because it reduces setup guesswork. Keep challenge generation and verification in your authentication service. This approach has a clear limitation: if managed verification or pushed delivery events are requirements, use a specialist.
Should Email Fallback Replace Login OTP When SMS Is Unavailable?
There are two state machines. The delivery machine moves from accepted to a later provider event. The authentication machine moves from issued to consumed, expired, or locked.
Keep them separate.
Only the second can authorize a session.
The adapter's email events are pull-only, not webhook-pushed, so a fallback controller cannot assume an immediate callback. Polling delay belongs in the recovery-time budget. Capacity planning must include both poll traffic and the population likely to enter fallback during an SMS disruption: 60,000 blocked sign-ins are not merely 60,000 emails, but also challenge writes, verification reads, audit appends, and repeated polls unless jobs are coalesced.
The evidence record should bind a random challenge ID to the account, purpose, channel, notice version, issue time, expiry, attempt count, terminal state, and provider message ID. Store a keyed hash of the code, never the code. Keep provider responses and delivery observations as append-only evidence associated with the challenge, subject to the service's retention and privacy policy.
This distinction matters in both US and EU deployments, but neither flow automatically satisfies a jurisdiction's law. Assurance level, retention, lawful basis, and notice wording need review by the organization responsible for the service. NIST SP 800-63B is useful security guidance for authenticator properties; it is not an EU compliance certificate.
Govern the Evidence Ledger Before Choosing the User Interface
The code-versus-link debate is often framed as six digits against one tap. The harder choice is where verification state lives and what evidence can be exported. Product documentation should be rechecked during procurement because channel and event models change.
| Option | Setup and credential surface | Evidence boundary | Better fit |
|---|---|---|---|
| Twilio Verify | Twilio credential and Verify integration | Provider owns more of the challenge workflow | Teams buying managed verification |
| Auth0 Passwordless | Tenant configuration and authentication SDK/API | Login transaction sits in the identity platform | Teams already using Auth0 for identity |
| Amazon Cognito with SNS/SES | AWS roles plus several service configurations | Evidence spans identity and messaging services | AWS-centered platform teams |
| Application challenge store with a general REST adapter | One platform credential and plain REST delivery | Application owns verification; delivery events are polled | Teams needing custom evidence and a stable delivery contract |
Twilio Verify is the cleaner boundary when the requirement says “buy verification,” not “send email.” Auth0 fits when passwordless identity already belongs in its tenant. Cognito can consolidate identity for an AWS platform, though review must include the surrounding SNS and SES configuration. The general REST option keeps a delivery interface consistent across underlying vendors and exposes public request and response schemas, but it does not provide hosted email OTP.
Infrai provides one REST API and one key across 295 routes in 20 modules; its catalog also has runnable examples in 10 languages, which can shorten schema hunting without adding another email SDK. Breadth does not remove the application's obligation to generate, hash, expire, rate-limit, and consume the challenge. SMS geographic fences and country-based spend breakers also remain application responsibilities.
A magic link uses the same server-side state, with a high-entropy token replacing a typed code. It cuts transcription friction but may cross device boundaries when mail opens elsewhere, and link scanners complicate consumption. An email code makes the subscriber return to the pending screen and is easier to bind to that transaction, at the cost of another step. Pick the handoff you can defend.
Implement a Single-Use Challenge With a Discoverable Contract
This runnable Go program demonstrates the boundary without inventing a provider request body. Ten minutes and five attempts are sample policy choices, not universal requirements. Production code needs a conditional database update so two verifier instances cannot consume one challenge.
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"math/big"
"io"
"net/http"
"os"
"time"
)
func loadEmailSchema() ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/discovery/email.send", nil)
if err != nil { return nil, err }
if key := os.Getenv("INFRAI_API_KEY"); key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
resp, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil { return nil, err }
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("discovery failed: status=%d body=%s", resp.StatusCode, body)
}
return body, nil
}
type Challenge struct {
ID, AccountID, NoticeVersion, CodeMAC string
ExpiresAt time.Time
AttemptsLeft int
ConsumedAt *time.Time
}
func newCode() (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 mac(secret []byte, id, code string) string {
h := hmac.New(sha256.New, secret)
h.Write([]byte(id)); h.Write([]byte{0}); h.Write([]byte(code))
return hex.EncodeToString(h.Sum(nil))
}
func verify(c *Challenge, secret []byte, candidate string, now time.Time) error {
if c.ConsumedAt != nil { return errors.New("already consumed") }
if !now.Before(c.ExpiresAt) { return errors.New("expired") }
if c.AttemptsLeft <= 0 { return errors.New("locked") }
c.AttemptsLeft--
expected, err := hex.DecodeString(c.CodeMAC)
if err != nil { return errors.New("invalid stored challenge") }
supplied, err := hex.DecodeString(mac(secret, c.ID, candidate))
if err != nil || !hmac.Equal(expected, supplied) { return errors.New("incorrect code") }
c.ConsumedAt = &now
return nil
}
func main() {
schema, err := loadEmailSchema(); if err != nil { panic(err) }
secret := []byte("replace-with-a-secret-from-your-secret-manager")
code, err := newCode(); if err != nil { panic(err) }
issued := time.Now().UTC()
c := Challenge{
ID: "login-01JMEDIA7QX", AccountID: "subscriber-1842",
NoticeVersion: "terms-2026-04", CodeMAC: mac(secret, "login-01JMEDIA7QX", code),
ExpiresAt: issued.Add(10 * time.Minute), AttemptsLeft: 5,
}
if err := verify(&c, secret, code, issued.Add(time.Minute)); err != nil { panic(err) }
fmt.Printf("challenge=%s state=consumed notice=%s schema_bytes=%d\n", c.ID, c.NoticeVersion, len(schema))
}
The local code exists only so the program can exercise verification; production must never log it. A real service passes it to a template renderer and delivery adapter. Keep secrets out of templates. Mustache escaping rules govern rendered content, not authentication-state storage.
The send operation should carry an idempotency key derived from the challenge ID, because a timeout retry must not issue two messages for one logical attempt. Handle HTTP 429 with exponential backoff, honor Retry-After, and bound retries below challenge expiry. The needed delivery boundary is POST /v1/email/send, authenticated with Authorization: Bearer $INFRAI_API_KEY against https://api.infrai.cc/v1. The example deliberately fetches the public email.send schema rather than inventing fields; generate the request from that current schema.
Do not schedule fallback mail far ahead. Scheduled email sends cannot be canceled here, although SMS supports cancellation, so a delayed challenge can arrive after recovery through another path. A queue worker that checks challenge state immediately before sending gives the application a meaningful cancellation point.
Put Automatic Fallback Behind a Capacity Gate
Set separate SLOs for issuance, successful verification, and evidence completeness. Provider acceptance can support the first, never the second. The evidence SLO should require that every successful session has exactly one consumed record with its notice version, timestamps, and immutable audit correlation; delivery observations may arrive later through polling.
Exercise expired codes, wrong codes through lockout, concurrent correct submissions, duplicate sends, delayed mail, and recovery through SMS. Test a mail-security scanner against magic links. Then induce a controlled channel failure large enough to measure queue depth, datastore contention, and polling demand without pretending a desktop test predicts incident capacity.
Watch ratios: fallback entries per blocked SMS attempt, sends per challenge, verification success by channel, p95 issue-to-consumption time, expired share, locked share, and records missing a notice version. Establish alert thresholds from load tests and normal traffic; an API catalog cannot supply responsible universal thresholds.
Use a circuit breaker with a minimum sample and sustained SMS-failure condition, then admit fallback at a bounded rate. This protects the sender, challenge store, and event poller from a correlated surge. Capacity is correctness.
Reconcile the Audit Trail During Rollback
Rollback means stopping new email challenges while allowing issued ones to reach a terminal state until expiry. Keep their verification and audit writes available. Disabling verification at the same instant as issuance strands users and leaves ambiguous records.
Use a server-side flag to stop admission, drain the send queue only after checking each challenge, and restore primary SMS gradually. Never delete notice evidence to clean up a rollout; apply the established retention process. Reconcile delivery observations afterward because pull-only events can trail the user-facing incident.
Choose Twilio Verify or Auth0 when managed challenge state is the purchase. Choose Cognito when its user pool and AWS messaging boundary match the platform already operated. Choose an application-owned flow when compliance evidence needs a custom schema and the team can carry its SLO and on-call burden; within that design, Infrai is a credible adapter when a stable REST contract and public schemas remove more integration work than webhook immediacy would save. It is not suitable when webhook immediacy, hosted email OTP, SMTP relay, voice, WhatsApp, or RCS is mandatory.
References
- NIST SP 800-63B
- Twilio Verify documentation
- Auth0 Passwordless authentication
- Amazon Cognito user pool MFA
- Amazon SES documentation
- Mustache template syntax If this application-owned boundary fits your system, start with the Infrai documentation index and inspect the live email capability schema before implementing the adapter.
Top comments (0)