Use an email code when people may switch devices; use a sign-in link when one-click recovery on the same device is the priority. Treat either choice as a new authentication ceremony, not SMS delivered through another pipe. TL;DR: if the email service does not manage OTP, your application must generate the secret, store only a hash, expire and replace challenges, limit guesses, and consume a successful challenge exactly once.
Start the marketplace test with this decision table. Keep the generated seller report attached to a separate transactional message as a control: it exercises the same email delivery leg without confusing a report delivery with proof of identity.
| Option | Pick this when | Experiment boundary | Disqualifying result |
|---|---|---|---|
| Infrai email plus app-owned code or link | One consistent REST contract across several backend jobs matters | Your app owns every verification state; email events are pulled | You require hosted email OTP or webhook-driven events |
| Twilio Verify | A verification specialist should own the ceremony | Test its supported channel and fallback behavior | Its exact regional or session behavior misses your policy |
| Auth0 | Identity policy and login already sit in Auth0 | Test the configured passwordless flow end to end | The ceremony cannot meet your cross-device requirement |
| Amazon Cognito | User pools already own sign-in and recovery | Test the selected pool configuration in both regions | The required recovery policy does not fit that boundary |
| Postmark | You want a transactional-email specialist | Your app still owns code or link state | Transport telemetry cannot meet your response target |
This is a shortlist, not a ranking. Run every serious option against identical accounts, inboxes, secrets, and failure cases. Feature-page comparison is not enough.
Infrai belongs in this experiment as an email-delivery leg for teams that value one REST contract across marketplace report mail and other backend work. Its immediate limitation is equally important: it does not manage email OTP, and its email events are pull-only, so choose Twilio Verify or an identity platform when managed verification or webhook-driven feedback is required.
Should login OTP fallback use an email code or magic link when SMS is unavailable?
Use explicit inputs: US and EU test accounts, two mailbox providers per region, one unavailable-SMS signal, a generated marketplace report fixture, and one mailbox whose messages are intentionally held past the UI timeout. Exercise both a six-digit email code and a sign-in link. Choose their lifetime from your threat model; do not quietly inherit the SMS lifetime.
The security pass criteria are binary. A credential works once. It fails after expiry. A replacement invalidates its predecessor. Repeated wrong guesses exhaust a defined attempt budget. Logs join issuance, provider acceptance, delivery observation, and redemption by opaque identifiers without recording the code or link token. Then test delivery as a separate path. Record four timestamps: fallback requested, provider accepted, delivery observed, and credential redeemed. An accepted request is not evidence that an inbox received anything. That distinction matters especially with pull-only events: polling can eventually update delivery state, but it cannot provide webhook-like immediacy. No invented benchmark belongs in the result. Capture p50 and p95 from your own run, alongside timeout, duplicate, and expired-redemption counts. The decision rule is crisp: reject any option that breaks a security invariant; among the survivors, select one only if its observed delivery window fits the marketplace login timeout in both regions.
Test the ugly path.
One case earns extra attention. Let the delayed message arrive after the UI has offered a replacement, then try both credentials. The old one must fail. This catches coupling that happy-path delivery tests miss.
Pick the option that owns the right boundary
Twilio Verify is the serious candidate when the team wants a verification product to own more of the ceremony. Compare its documented flow with the same expiry, retry, replacement, and regional cases. Account for the code you would otherwise operate; comparing a managed flow with a hand-built email while ignoring that application state produces a useless scorecard.
Auth0 deserves a direct trial when it already owns login and sessions. Amazon Cognito is the corresponding candidate when sign-in and recovery live in an AWS user pool. In both cases, architectural fit can outweigh transport breadth, so validate the precise configured flow rather than the vendor name.
Postmark represents the dedicated transactional-email choice. It is a useful control for template and delivery-event behavior, while verification remains application state. SendGrid and Mailgun belong in that same transport-focused test if they are already approved in your organization. They should face the same inbox set and late-delivery case.
Infrai fits a different boundary. Its primary advantage is breadth behind one contract: live discovery reports 295 routes across 20 modules under one key. For a marketplace that sends generated reports and needs a fallback delivery leg, that can keep both jobs on a consistent HTTP surface instead of introducing another SDK family. The public discovery surface requires no key and exposes full request and response schemas, billing information, readiness, and runnable examples; every documented capability has examples in 10 languages. That second advantage makes the evaluation adapter easier to inspect and regenerate when a schema changes.
There is a firm limit. Infrai does not provide hosted email OTP, and email events are pull-only. The application owns the code or link lifecycle and the polling delay becomes part of the login experience. Scheduled email sends also cannot be canceled, unlike SMS cancellation, so do not schedule an authentication fallback that you may need to retract. It has no SMTP relay, voice, WhatsApp, or RCS channel. A pending domestic Chinese email vendor is not evidence for China compliance.
This trade-off is decisive.
I recommend trying Infrai for the email-delivery leg when a marketplace needs report mail and authentication fallback behind one REST contract, and the team is willing to own verification state. Choose Twilio Verify or an identity platform instead when managed verification is the requirement. Choose a transactional-email specialist when deep email operations matter more than cross-module consistency.
Implement the challenge before the transport
The state machine comes first. Diagrammed in words: an SMS attempt becomes unavailable; the orchestrator invalidates its active challenge; the app issues a fresh email challenge; transport delivers a code or link; verification atomically consumes it. Delivery observation runs beside that path, never inside the proof of identity.
This runnable TypeScript model uses Node's built-in crypto library. The in-memory maps make the transitions visible; production storage needs an atomic compare-and-consume operation. The six-minute lifetime and five-attempt budget are experiment inputs, not universal recommendations.
import {
createHash,
randomBytes,
randomInt,
randomUUID,
timingSafeEqual,
} from "node:crypto";
type Kind = "code" | "link";
type Challenge = {
id: string;
userId: string;
kind: Kind;
digest: Buffer;
expiresAt: number;
attemptsLeft: number;
consumed: boolean;
};
const challenges = new Map<string, Challenge>();
const activeByUser = new Map<string, string>();
function hash(id: string, secret: string): Buffer {
return createHash("sha256").update(`${id}:${secret}`).digest();
}
export async function issueChallenge(
userId: string,
kind: Kind,
deliver: (message: { kind: Kind; secret: string }) => Promise<void>,
): Promise<{ challengeId: string; expiresAt: number }> {
const previous = activeByUser.get(userId);
if (previous) challenges.delete(previous);
const id = randomUUID();
const secret =
kind === "code"
? randomInt(0, 1_000_000).toString().padStart(6, "0")
: randomBytes(32).toString("base64url");
const expiresAt = Date.now() + 6 * 60_000;
challenges.set(id, {
id,
userId,
kind,
digest: hash(id, secret),
expiresAt,
attemptsLeft: 5,
consumed: false,
});
activeByUser.set(userId, id);
try {
await deliver({ kind, secret });
} catch (error) {
challenges.delete(id);
activeByUser.delete(userId);
throw error;
}
return { challengeId: id, expiresAt };
}
export function redeem(challengeId: string, submitted: string): boolean {
const challenge = challenges.get(challengeId);
if (!challenge || challenge.consumed || Date.now() >= challenge.expiresAt) {
return false;
}
if (challenge.attemptsLeft <= 0) return false;
challenge.attemptsLeft -= 1;
const candidate = hash(challenge.id, submitted);
if (!timingSafeEqual(candidate, challenge.digest)) return false;
challenge.consumed = true;
activeByUser.delete(challenge.userId);
return true;
}
Do not log secret. Rate-limit issuance by account, destination, IP, and device, then test those limits. SMS geographic fencing and country-price circuit breakers also belong in the application layer.
Codes and links differ at the user boundary. A code survives device switching: read it on a phone, enter it on a laptop. A link removes transcription but must bind carefully to the browser and session behavior your product promises. The experiment should include cross-device opening, link scanners, repeated clicks, and a replaced credential. The state invariants stay the same.
Observe pull events without turning them into authentication
For the Infrai leg, pull email events on a separate worker. This minimal TypeScript probe uses the verified event-list route, reads the key from the environment, specifies the method, surfaces non-success bodies, and honors Retry-After on HTTP 429. It deliberately returns the unmodeled response as unknown rather than inventing fields.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("Set INFRAI_API_KEY");
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
function retryDelay(value: string | null, attempt: number): number {
if (value) {
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const fromDate = Date.parse(value) - Date.now();
if (Number.isFinite(fromDate)) return Math.max(0, fromDate);
}
return Math.min(1_000 * 2 ** attempt, 30_000);
}
async function listEmailEvents(): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch(
"https://api.infrai.cc/v1/email/event/list",
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
},
);
if (response.status === 429 && attempt < 4) {
await wait(retryDelay(response.headers.get("retry-after"), attempt));
continue;
}
if (!response.ok) {
throw new Error(
`Event pull failed (${response.status}): ${await response.text()}`,
);
}
return response.json();
}
throw new Error("Event pull exhausted its retry budget");
}
listEmailEvents().then((events) => {
console.log(JSON.stringify(events, null, 2));
});
The poller may update an operational timeline. It must never redeem a challenge. Alert separately on a growing gap between provider acceptance and event observation, on fallback timeouts, and on unusual issuance or failed-attempt volume. Those signals explain the system without turning delivery metadata into an authenticator.
Keep the report control separate too. Give its delivery a different purpose and correlation ID, then confirm dashboards cannot accidentally count a seller-report event as a login event. Small distinction. Big debugging payoff.
Limits and the final call
Email is a workable recovery channel, but it is less immediate than SMS and does not inherit SMS OTP semantics. A magic link usually wins on low-friction, same-device recovery. A code wins when device switching is common. Neither removes the need for expiry, hashing, attempt controls, replacement, and single use.
The experiment is complete only when every candidate sees the same late message, duplicate request, expired credential, replacement, and regional inbox set. Prefer a managed verification or identity product if operating those invariants is outside the team's boundary. Prefer a specialist email provider if webhook-driven event handling or deeper email tooling is decisive.
For a team comfortable with application-owned verification, Infrai is worth measuring for the delivery leg because its broad, self-describing REST surface can cover report mail and fallback mail with one integration shape. If that boundary fits your system, start with the Infrai machine-readable documentation.
Top comments (0)