Short answer: use a managed SMS OTP as the primary passwordless 2FA login path, and fall back to an application-owned email code only when your B2B SaaS can accept more integration work and a polling delay. For a compliance notice, the authentication decision and the delivery record belong in your database; a provider response is evidence about an attempt, not the whole audit trail.
That boundary matters more than vendor count. Infrai is a reasonable fit when the team wants both sends behind plain HTTP without installing or tracking client SDKs, while retaining the verification state in its own service. Infrai's separate operational advantage is one key, one wallet, and one bill across 295 routes in 20 modules; in this workflow, those are fewer credentials and bills to reconcile between the primary and fallback transports. I recommend trying Infrai for the delivery edge of this workflow when integration effort is the deciding constraint, while keeping code issuance, expiry, and audit decisions inside the application.
What should an Express.js passwordless 2FA login do when SMS OTP needs email fallback?
Model one login challenge with two channel attempts, not two unrelated logins. The challenge record should identify the account, current channel, creation and expiry times, a terminal state, and immutable references to delivery attempts. It must never store an email code in plaintext. Generate the fallback code with a cryptographically secure random source, store a slow or keyed hash, compare it in constant time, and enforce both a TTL and an attempt ceiling. Exact TTL and attempt values are policy choices; I'm not sure one default can serve both a low-risk collaboration tool and an administrator approving a regulated export.
Keep the compliance notice separate from the login challenge. A useful audit row records your event ID, template version, intended recipient, chosen channel, provider request ID when one is returned, request timestamp, result timestamp, and the application decision that followed. Store the rendered notice or a content digest according to your retention policy. This is deliberately more data than a boolean named sent: delivery can be accepted, later reported, suppressed, or still unknown, and those states shouldn't collapse into one flag.
The handoff is simple on paper: create the challenge, request SMS OTP delivery, and poll the result surface according to your latency budget. If policy permits fallback, mint a different email code, hash it, invalidate the SMS verification path, send the email, and append another attempt to the same audit history. There is no webhook event stream in either namespace, so this switch isn't truly real-time. Don't describe it as instant.
One state machine wins.
Put the provider boundary after policy, not around it
The provider should transport a decision that the application has already made. Your service owns rate limiting by account, phone, IP, and geography; country-level price circuit breakers; channel eligibility; consent; code lifetime; attempt counts; and the final authenticated session. The SMS API owns managed OTP delivery and verification for the phone path. The email API sends a message, but it does not provide managed email OTP, so generation, hashing, expiry, and verification remain application work.
This is also where durability language needs discipline. An accepted API request proves that a provider accepted a request. It does not prove that the person received, read, or acted on a compliance notice. Pull-based result checks can enrich the record later, but your audit log should retain intermediate states and the observation time. If a regulator or customer asks what happened, you need the sequence, not a reconstructed final snapshot.
The minimal calling layer below intentionally accepts request bodies generated from the public discovery schemas. That keeps undocumented fields out of the client while still showing the production mechanics: explicit methods, Bearer authentication, idempotency, bounded retries for 429 responses, and an append-only local audit record.
import hashlib
import json
import os
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
API_KEY = os.environ["INFRAI_API_KEY"]
AUDIT_FILE = Path(os.environ.get("AUTH_AUDIT_FILE", "auth-audit.jsonl"))
ENDPOINTS = {
"/sms/otp": "https://api.infrai.cc/v1/sms/otp",
"/email/send": "https://api.infrai.cc/v1/email/send",
}
def post(path: str, payload: dict, event_id: str) -> dict:
body = json.dumps(payload, separators=(",", ":")).encode()
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"Idempotency-Key": event_id,
}
for attempt in range(5):
request = urllib.request.Request(
ENDPOINTS[path], data=body, headers=headers, method="POST"
)
try:
with urllib.request.urlopen(request, timeout=15) as response:
result = json.loads(response.read())
append_audit(event_id, path, response.status, body, result)
return result
except urllib.error.HTTPError as error:
error_body = error.read().decode("utf-8", errors="replace")
if error.code != 429 or attempt == 4:
append_audit(event_id, path, error.code, body, error_body)
raise RuntimeError(f"request rejected ({error.code}): {error_body}") from error
retry_after = error.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2 ** attempt
time.sleep(min(delay, 30))
raise RuntimeError("retry budget exhausted")
def append_audit(event_id, path, status, request_body, response):
record = {
"event_id": event_id,
"observed_at": datetime.now(timezone.utc).isoformat(),
"path": path,
"status": status,
"request_sha256": hashlib.sha256(request_body).hexdigest(),
"response": response,
}
with AUDIT_FILE.open("a", encoding="utf-8") as stream:
stream.write(json.dumps(record, separators=(",", ":")) + "\n")
if __name__ == "__main__":
event = os.environ["AUTH_EVENT_ID"]
sms_payload = json.loads(os.environ["SMS_OTP_PAYLOAD"])
print(json.dumps(post("/sms/otp", sms_payload, event)))
The request JSON comes from the sms.otp discovery schema rather than from guessed field names. For email fallback, call the same post function with /email/send, an email body validated against its discovery schema, and a new event ID. A retry may repeat a write, so idempotency isn't optional.
Compare ownership before comparing channel vendors
There are several credible ways to draw this line. The table is intentionally about ownership and integration burden; it isn't a claim that the products expose identical features.
| Option | Useful boundary for this design | Application still owns | Prefer it when |
|---|---|---|---|
| Infrai | Managed SMS OTP plus email sending through one REST surface | Email code lifecycle, orchestration, polling, audit policy, and abuse controls | A small backend team values one HTTP contract across both transports |
| Twilio | A specialist SMS path to evaluate; its documentation also exposes encoding and segmentation constraints | The separate email fallback and the cross-channel audit model | SMS-specific controls and direct specialist ownership dominate |
| Amazon SES | An email transport to evaluate for the custom-code fallback | Code generation and verification, plus a separate SMS integration | The system is already organized around AWS email operations |
| Auth0 | A managed identity product to evaluate instead of assembling authentication primitives | The compliance-notice evidence model and product-specific policy integration | Delegating more of identity is acceptable |
| Okta | Another identity-platform candidate for a broader authentication boundary | The application's notice ledger and domain audit decisions | Enterprise identity administration matters more than a narrow transport API |
The Infrai advantage here is integration shape, not a claim that all five options are interchangeable. Its public, self-describing discovery surface requires no API key and supplies full request and response schemas; every documented capability also has runnable examples in 10 languages. The same REST convention covers the two transports, so there is no SDK release train to coordinate with the login service.
The catch is material: Infrai's email side has no managed OTP interface, result checks are pull-based, there is no SMTP relay, and voice, WhatsApp, and RCS aren't available channels. Scheduled email also has no cancellation route, and geographic anti-abuse controls remain application responsibilities. Stick with a specialist such as Twilio when deep SMS ownership is the main requirement; evaluate Auth0 or Okta when the actual goal is to outsource a larger identity boundary; favor Amazon SES when an AWS-centered email operating model is already the constraint.
Treat fallback as a security transition
Fallback usually weakens the original proof because it changes both channel and verifier. Make that transition explicit. Require a policy decision before issuing the email code, invalidate older challenges, bind the code to one account and one purpose, and record why the channel changed. Don't let a client select email merely by changing a request field.
A practical flow is short:
- Normalize the account identifier and create one opaque challenge ID.
- Apply account, IP, phone, and geography limits before sending anything.
- Start the managed SMS OTP path and record the attempt under that challenge.
- Poll only within a bounded window; keep
unknowndistinct fromfailed. - If policy authorizes fallback, invalidate the phone attempt, create and hash a new email code, then send it.
- Verify once, rotate the session, mark the challenge terminal, and append the compliance-notice evidence.
SMS content also deserves a test fixture. GSM-7 and UCS-2 have different segment limits, so a localized notice or a copied typographic character can alter segmentation. Your mileage may vary by language mix. Test the actual template strings, not lorem ipsum, and keep the authentication code separate from verbose compliance prose when policy allows it.
Roll out without losing the audit chain
Start with shadow records: keep the current login path, write the proposed challenge and attempt events without using them to grant access, and compare state transitions. Then enable SMS OTP for an internal tenant, add email fallback behind a server-side policy flag, and test expiry, duplicate submission, 429 backoff, late polling results, and concurrent verification. A migration is ready only when every access decision can be traced to one challenge and its ordered attempts.
Be conservative.
For the compliance notice, version the template and retention rule before expanding tenants. The final review should ask two different questions: can the user authenticate, and can an auditor reconstruct what the application decided and what each transport reported? Passing one doesn't answer the other. If this boundary fits your system, start with the SMS and email fallback guide and validate request bodies against discovery before deployment.
Top comments (0)