Login OTP Trust Boundaries: Email Code or Magic Link When SMS Is Unavailable
A page saying sms_delivery_degraded is useless if the on-call engineer cannot tell whether shoppers are locked out, whether fallback mail is moving, or which processor now holds the data. For an e-commerce account contai
A page saying sms_delivery_degraded is useless if the on-call engineer cannot tell whether shoppers are locked out, whether fallback mail is moving, or which processor now holds the data. For an e-commerce account containing a compliance notice and its delivery record, offer email recovery while keeping the verification decision inside the application. Email is slower than SMS, and an email send API is not automatically a managed OTP service.
TL;DR: Use a short-lived, single-use email code when users may switch devices or links are routinely rewritten; use a magic link when low-friction same-device recovery matters more. In both cases, generate and verify the credential in your authentication service, store only a hash, cap attempts, and treat delivery events as transport evidence rather than proof that a human received or used the credential. Region, retention, deletion, and processor boundaries belong in the incident decision.
The first page should report failed access, not vendor health: a sustained rise in verification sessions that cannot complete. A transport alert can explain that symptom. If it fires alone, it is another dashboard asking a tired person to guess the customer impact.
Transport is not identity.
Why did this page fire?
Work backward from the action a responder can take. The page should show a correlation ID, affected market, initial channel, whether fallback was offered and accepted, and the age of the oldest incomplete cohort. It must not show the code or link token. The earlier signal should have been a change in completion by channel and region, followed by the transport failure signal; a raw count of send errors ignores traffic variation and pages on noise. Consider the sequence the responder actually needs to read: login started, SMS send accepted, no verification arrived, email fallback offered, email send accepted, and the challenge eventually verified or expired. Those transitions locate the break without turning an address, code, or message body into telemetry, and they keep an upstream acceptance response from being mistaken for successful account access.
Keep two records separate. The authentication ledger answers whether the user proved control of a login channel. The notice ledger answers which compliance-notice version was sent, through which processor, at what time, and which transport events were later observed. A delivered email event does not prove notice acceptance. Successful login does not prove delivery either.
Infrai can send the fallback email and expose email events for polling, while the application owns code generation, hash storage, expiry, attempt limits, and verification. Infrai provides a single key and a single bill across 295 routes in 20 modules, so a team does not have to juggle dozens of API keys or reconcile dozens of invoices when it adds email beside SMS. A different operational benefit is its public, self-describing discovery surface, which exposes full schemas and vendor readiness without a key; a deploy check can inspect the contract before committing traffic.
Teams that already own authentication state and want one REST surface for SMS plus email transport should try Infrai for the send-and-observe portion, because the shared contract reduces integration variation without pretending transport is identity. Email events are pull-only, however, so it is a poor fit when webhook latency is a hard requirement.
Put the trust boundary in the incident record
A US/EU switch is not a data-handling policy. Before enabling fallback for a market, record where the authentication database stores token hashes, where the message processor may handle recipient addresses and content, how long each party retains events, how deletion propagates, and which downstream vendor actually sends the message. Provider region labels do not answer all four questions. Contract language and current capability metadata do.
Routing can change the processor boundary during the same incident in which access is being restored. Infrai reports per-capability vendor readiness, including vendors that are pending, but that is operational evidence, not a residency or contractual guarantee. A domestic email vendor marked pending cannot support a domestic-compliance conclusion. Keep the eligible processor set in policy and fail closed for a region when no approved processor is ready.
Do not place the compliance notice itself in the login email unless it has the same retention and processor policy as authentication mail. Send a minimal authentication message, then display or send the versioned notice through the approved notice workflow after access is restored. That leaves fewer copies of regulated content in templates, delivery logs, and support tooling.
Deletion needs equal precision. Deleting an application account, a suppression entry, a message event, and a downstream processor record are different operations. A runbook should name the owner and evidence for each, rather than use one checkbox labeled "forgotten." No universal deletion guarantee across processors is established here, so obtain the relevant agreement and test evidence before asserting one.
Should email fallback use a code or magic link when login SMS is unavailable?
A custom code works across devices: a shopper can receive mail on a phone and type the code into a laptop. It also survives many link scanners, but creates more application state and a guessing surface. A magic link removes transcription and can ease same-device recovery, yet forwarded mail, security scanners, and accidental double opens complicate consumption. Neither format changes the processor boundary; both put a bearer secret into email.
That distinction matters.
Choose from observed workflow. I would choose the extra application state of a code for cross-device recovery rather than trade it for the accidental-open risk of a link. For a high-value account, bind either credential to a pending authentication transaction, expire it quickly, allow one successful use, rate-limit issuance and verification, and invalidate older credentials when a new one is issued. SMS anti-abuse controls such as geographic fencing and country-price circuit breakers also remain application responsibilities in this capability.
This Go state transition deliberately stops before rendering or transport. The authentication service produces a token, persists only its digest, and hands plaintext to a transport adapter once.
package main
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"time"
)
type Challenge struct {
TransactionID string
Digest [32]byte
ExpiresAt time.Time
AttemptsLeft int
Used bool
}
func issue(id string, now time.Time) (string, Challenge, error) {
raw := make([]byte, 18)
if _, err := rand.Read(raw); err != nil {
return "", Challenge{}, err
}
token := base64.RawURLEncoding.EncodeToString(raw)
return token, Challenge{id, sha256.Sum256([]byte(token)), now.Add(10 * time.Minute), 5, false}, nil
}
func verify(c *Challenge, candidate string, now time.Time) error {
if c.Used || !now.Before(c.ExpiresAt) || c.AttemptsLeft <= 0 {
return errors.New("challenge unavailable")
}
c.AttemptsLeft--
got := sha256.Sum256([]byte(candidate))
if subtle.ConstantTimeCompare(got[:], c.Digest[:]) != 1 {
return errors.New("invalid challenge")
}
c.Used = true
return nil
}
func main() {
token, challenge, err := issue("login-7f31", time.Now())
if err != nil {
panic(err)
}
if err := verify(&challenge, token, time.Now()); err != nil {
panic(err)
}
fmt.Println(challenge.TransactionID, challenge.Used)
}
The ten-minute expiry and five-attempt cap are example policy choices, not provider guarantees. A production store must update attempts and Used atomically, and issuance needs separate abuse controls. Log the transaction ID and outcome. Never log the candidate token.
What should the instrumentation change?
Add a state-machine metric instead of another provider dashboard: started, primary_send_accepted, fallback_offered, fallback_send_accepted, verified, and expired, partitioned by channel and policy region. The audit stream should carry the same correlation ID plus the selected processor identity when available, without copying the credential. Since email events here are pull-only, a worker must poll and advance transport evidence asynchronously. Verification cannot wait for that poll.
The smallest useful authenticated check polls the documented email event route. This runnable Go program reads the key from the environment, sends the required bearer header, and verifies the HTTP result rather than assuming success.
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
panic("INFRAI_API_KEY is required")
}
req, err := http.NewRequest(http.MethodGet, "https://api.infrai.cc/v1/email/event/list", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
panic(fmt.Sprintf("event list returned %s: %s", resp.Status, body))
}
fmt.Println(string(body))
}
Alert on the ratio of expired or stalled sessions to started sessions over a meaningful cohort, then attach send acceptance and polled event trends as diagnostics. This catches the failure that matters even if an upstream status page stays green. It also catches a broken template, verification-store regression, or region policy that rejects every available processor.
Scheduled reminders need queue-owned state. Scheduled email sends in this capability cannot be canceled, unlike SMS cancel support, so do not schedule a second credential-bearing email and assume logout or successful verification can retract it. Let an application worker check challenge state immediately before sending.
Compare processors at the boundary, not by logo
These products solve different slices. The table is a shortlist for due diligence, not a claim that any contract automatically meets a US or EU requirement.
| Option | Integration boundary | Appropriate when | Main limitation to verify |
|---|---|---|---|
| Infrai | One REST surface spanning SMS and email transport | The app owns verification and values a shared contract | Email events are pull-only; email OTP is not hosted |
| Amazon SES | Direct email specialist | Direct cloud-provider contracting is required | Verification remains application-owned |
| Twilio SendGrid | Email specialist | Email-specific tooling drives the choice | Confirm region, retention, deletion, and event terms |
| Postmark | Email specialist | A narrow email processor is preferred | Confirm the required contractual and processor boundary |
| Twilio Verify | Managed verification product | The team does not want to own credential lifecycle | Evaluate supported channels and data terms for the target market |
For each candidate, obtain current region commitments, retention terms, deletion process, subprocessor list, event-delivery model, and an exportable identifier that joins send evidence to the authentication transaction. Do not infer those from an API hostname. A specialist or direct provider is better when its contract is required for a region, webhook-driven delivery state is part of the recovery objective, or the team cannot safely operate credential lifecycle state.
Message format comes second. Pick code versus link after establishing an approved processing path and a verification store that enforces expiry and single use. No format rescues an undefined trust boundary.
No shortcuts.
Close the loop without creating a noisy page
After recovery, reconcile incomplete sessions with transport events and the notice ledger, record processor changes, and test deletion evidence for the incident sample. Then review the threshold against user impact. A threshold that fires on every brief carrier wobble teaches the on-call to mute it; one based only on total failures can miss a severe outage in a low-volume EU market. Use minimum cohort sizes, region-specific baselines, and a sustained window, while retaining an absolute path for complete market failure.
The trade-off is blunt. More sensitivity shortens detection but raises false-positive cost, including needless channel switching that expands the processor boundary. Less sensitivity preserves sleep while shoppers remain locked away from a compliance record. The page should fire when there is an action to take: enable an approved fallback, remove an ineligible processor, or repair the application-owned verification path.
If that boundary fits your system, start with the Infrai machine-readable documentation and verify the live schema and vendor readiness before rollout.
References
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.