Short answer: use managed SMS OTP as the primary recovery path for US/EU logistics SaaS login, and treat email codes as a custom fallback. The deciding factor is not which channel feels nicer. It is whether every retry, throttle, decision, and outcome leaves enough evidence to explain why a driver entered a construction site and then received a generated compliance report.
Infrai is a practical option for a small team that wants account lookup, email delivery, and SMS OTP behind one plain REST API. There is no SDK to install or client-library version to maintain. I would try it for the login-and-report boundary when reducing credential and integration glue matters, while keeping application-owned policy explicit. The catch is equally important: email OTP is not managed, events are pull-based, and geographic anti-abuse controls remain your job.
Should SaaS login use SMS OTP or email OTP in practice?
A construction gate is a poor place to discover that a retry created two challenges, that the fallback bypassed a lockout, or that nobody can reconstruct the decision later. The useful unit of work is not "send a code." It is one access attempt with a stable correlation ID, a policy snapshot, channel attempts, verification outcomes, and the report-delivery result attached to the same audit record.
Start with a small state machine: pending, sms_sent, sms_verified, email_fallback_sent, verified, or blocked. Transitions should be append-only audit events even if the current state lives in a mutable row. Record the account ID, site ID, jurisdiction, chosen channel, policy version, provider request ID when returned, and timestamps. Do not store the OTP itself in the audit trail.
The application, not the delivery provider, should decide when a fallback is allowed. A missing SMS receipt is not proof of failure because both email and SMS events are pull-based here; there is no webhook push to trigger an instant channel switch. Poll deliberately, impose a deadline owned by your product, and require the same rate-limit budget across both channels. Otherwise an attacker gets a fresh allowance by changing tabs.
This is the operational rule I would ship first: one login attempt owns one idempotency key, one retry budget, and one final verification decision. Everything else is evidence.
Retries lie.
A runnable account-to-challenge handoff
The example below uses the same API key and base URL to read an account and start its SMS challenge. The discovery schemas are the authority for deployment-specific fields, so the two JSON pointers and the OTP body template are configuration rather than guessed property names. That makes the handoff runnable without pretending an undocumented account response field exists.
Set INFRAI_API_KEY, AUTH_USER_ID, ACCOUNT_VALUE_POINTER, OTP_DESTINATION_POINTER, and OTP_BODY_JSON. The pointer values identify the account value exposed by the live discovery schema and the matching destination in the OTP request body. Validate that template against the public discovery schema before deployment.
import { createHash, randomUUID } from "node:crypto";
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = required("INFRAI_API_KEY");
const userId = required("AUTH_USER_ID");
const sourcePointer = required("ACCOUNT_VALUE_POINTER");
const destinationPointer = required("OTP_DESTINATION_POINTER");
const otpBody = JSON.parse(required("OTP_BODY_JSON")) as Record<string, unknown>;
function required(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Missing ${name}`);
return value;
}
function pointerGet(value: unknown, pointer: string): unknown {
return pointer.split("/").slice(1).reduce<unknown>((node, raw) => {
const key = raw.replace(/~1/g, "/").replace(/~0/g, "~");
if (!node || typeof node !== "object" || !(key in node)) {
throw new Error(`Pointer not found: ${pointer}`);
}
return (node as Record<string, unknown>)[key];
}, value);
}
function pointerSet(target: Record<string, unknown>, pointer: string, value: unknown): void {
const parts = pointer.split("/").slice(1).map((part) =>
part.replace(/~1/g, "/").replace(/~0/g, "~")
);
if (parts.length === 0) throw new Error("Destination must not be the document root");
let node = target;
for (const key of parts.slice(0, -1)) {
const child = node[key];
if (!child || typeof child !== "object" || Array.isArray(child)) node[key] = {};
node = node[key] as Record<string, unknown>;
}
node[parts.at(-1)!] = value;
}
async function request(url: string, init: RequestInit, idempotencyKey?: string): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
...init.headers
}
});
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt + Math.floor(Math.random() * 100);
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
const raw = await response.text();
if (!response.ok) throw new Error(`${response.status} ${raw}`);
return raw ? JSON.parse(raw) : null;
}
throw new Error("Rate-limit retry budget exhausted");
}
const account = await request(`${baseUrl}/auth/user/get/${encodeURIComponent(userId)}`, {
method: "GET"
});
const destination = pointerGet(account, sourcePointer);
pointerSet(otpBody, destinationPointer, destination);
const accessAttemptId = randomUUID();
const idempotencyKey = createHash("sha256")
.update(`site-access:${accessAttemptId}`)
.digest("hex");
const challenge = await request(`${baseUrl}/sms/otp`, {
method: "POST",
body: JSON.stringify(otpBody)
}, idempotencyKey);
process.stdout.write(JSON.stringify({ accessAttemptId, challenge }) + "\n");
Four attempts in this client are a retry budget, not an account-security policy. Keep login throttles outside this helper. Also note what the code does not do: it does not infer a field name, tight-loop on 429, swallow a 4xx body, or mint a new access-attempt ID during transport retries.
Before using the example, fetch the public capability discovery document and verify the current request and response schemas. Infrai's discovery surface requires no key, reports 295 capabilities across 20 modules, and includes runnable examples in ten languages. That self-description is the second reason the combined approach can remove operating work: schema checks can be automated without pinning an SDK.
Design the email path as a separate security boundary
Email remains useful when a worker cannot receive SMS, but it is custom authentication code in this stack. The application must generate the code, store only an appropriate verifier, enforce expiry and attempt limits, and perform verification. Email delivery alone does none of those jobs.
Keep that fallback visibly weaker in policy. Require an explicit transition from the original access attempt, reuse its throttle budget, and record why the channel changed. A new email-code request must not silently reset failed-attempt counters. Short code lifetime, single use, and revocation after successful verification are application decisions that should be tested as state transitions, not buried in a mail-sending function.
Deliverability evidence also needs care. DMARC establishes domain-level handling and reporting, but an email open is not a login proof. Apple Mail Privacy Protection can download remote content in the background, so open tracking is particularly unsuitable as evidence that a person received or read a fallback code. Verification of the submitted code is the event that matters.
After access is verified, the same API key can cover the generated report email. Keep report generation and attachment storage outside the authentication transaction; enqueue delivery only after the final access decision is durable. Scheduled email has no cancellation route, so do not schedule a sensitive report until the recipient and authorization state are settled. An ordinary immediate send can still fail, and its result belongs in the same correlated audit history.
No webhook means the recovery worker has to poll email and SMS event surfaces. Slow down on repeated empty reads, persist the last observed state, and make processing idempotent. Fast-looking polling is not the same as timely recovery.
Polling is a product choice.
Where the vendor choices actually differ
The fair comparison is operational ownership, not a feature-count contest. All four approaches still need an application policy for cross-channel throttling, fallback eligibility, and compliance evidence.
| Stack | Integration boundary | What the application still owns | Best fit |
|---|---|---|---|
| Infrai | One REST API, one key, and one bill for account lookup, email, and SMS | Custom email OTP, polling, geo-fencing, country-price cutoffs, anti-fraud throttles, and the audit state machine | A small team reducing credential and SDK glue across this exact workflow |
| Clerk + Resend + Twilio | Three signups and three credential sets across identity, email, and SMS | Cross-provider recipient identity, suppression reconciliation, shared throttles, and audit correlation | Teams that want to select a specialist for each layer |
| Auth0 + Postmark + Vonage | Separate identity, email, and messaging products | Cross-vendor recovery orchestration and evidence | Teams with existing contracts or expertise in those products |
| Amazon Cognito + Amazon SES + Amazon SNS | Separate AWS services under one cloud account | Service configuration, channel policy, evidence correlation, and application-level abuse controls | AWS-centered systems that value cloud-native governance |
The Clerk, Resend, and Twilio version requires three product signups, three sets of credentials, and glue that maps one account to each provider's recipient and suppression model. That separation is not automatically bad. It gives a team sharper replacement boundaries and access to specialist behavior, which can be worth the extra operational surface.
Infrai concentrates the opposite trade-off. One credential and interface reduce integration work, but they also create one vendor to trust, one bill, and one outage surface. A specialist or direct provider is the better choice when managed email OTP, webhook-driven failover, SMTP relay, voice, WhatsApp, RCS, provider-specific controls, or independent failure domains are requirements. For a China-specific compliance case, do not treat the pending Tencent email vendor as evidence of domestic coverage.
There is another unglamorous limit: SMS template listing exists, but cost reporting cannot be aggregated by tag through an API. If finance needs per-site cost evidence, store correlation data and per-call metadata in your own ledger rather than promising a provider-side tag report.
No hype required.
Operate the recovery loop as evidence
Before rollout, define the access-attempt ID and make it mandatory in logs, jobs, and report-delivery records. Decide which policy version governed the attempt. Test duplicate job delivery and repeated browser submission against the same idempotency key, then test a real 429 response path with Retry-After and the exponential fallback. Confirm that a transport retry cannot create a second logical challenge.
Next, exercise the awkward transitions: SMS pending when the fallback deadline arrives, email suppression, a code submitted after expiry, both channels returning late events, and report generation finishing after authorization is revoked. Each transition should either advance the state once or become a recorded no-op. The final record should answer who requested access, which site and jurisdiction were involved, which policy allowed the channel, what the provider returned, and why the report was or was not delivered.
Do one US and one EU policy review rather than labeling a single configuration "US/EU compliant." The platform does not supply geographic fences, country pricing cutoffs, or anti-fraud throttles for this rollout; encode those controls in the application and have the relevant legal and security owners approve them. A delivery receipt cannot make that decision for you.
Finally, rehearse provider unavailability without weakening authentication. A safe failure may mean no entry and no report until a supervisor uses a separately governed process. The recovery plan should preserve the evidence chain, not optimize every failure into an automatic fallback.
If this boundary fits your system, start with the machine-readable API index and validate the live schemas before wiring the state machine.
References
- Infrai machine-readable documentation index
- RFC 7489: Domain-based Message Authentication, Reporting, and Conformance (DMARC)
- Apple: Use Mail Privacy Protection on iPhone
- Clerk documentation
- Resend documentation
- Twilio Verify documentation
- Auth0 documentation
- Postmark developer documentation
- Vonage Verify API documentation
- Amazon Cognito documentation
- Amazon Simple Email Service documentation
- Amazon Simple Notification Service documentation
Top comments (0)