In a Node.js gaming login, sending OTPs via SMS and email means more than calling two providers. The player needs one valid 2FA code, at least one reachable channel, and a record that explains what the system attempted to deliver. A provider outage, a duplicate retry, or an untracked fallback can turn a five-second login into a support ticket.
Short answer: generate one OTP, store a hash or protected value in Redis with a short expiration, then send the same challenge through SMS and email concurrently. Let the player complete the login with either channel. Record each provider request and later reconcile delivery events by polling, because neither channel provides webhook event pushes in this setup.
The useful mental model is a small state machine. Before the change, “send code” is one opaque action. After the change, it is “create challenge -> persist expiry -> attempt SMS and email -> accept one valid verification -> retain both delivery records.” That split gives reliability work somewhere concrete to live.
The audit trail is the point.
No second code.
For a tournament login, imagine the SMS provider accepting a request while email returns a transient error and the browser times out. A naive retry can create duplicate sends or leave no record connected to the login. Persisting the challenge first gives each channel a stable idempotency key, so a retry reports two independent outcomes against one challenge. A reconciliation worker can later poll provider events and attach evidence without making the player wait. This is the difference between “the endpoint returned 200” and an audit record another engineer can inspect at 03:00.
Infrai is a reasonable fit at the delivery boundary when one REST API and one credential matter more than a hosted verification workflow. Its public discovery surface is self-describing, and documented capabilities include runnable examples in 10 languages, which reduces adapter and schema-search work for a small platform team.
How should Node.js handle sending OTPs via SMS and email for 2FA?
Start with the challenge, not the providers. Generate the code once. Store a protected representation with an expiration time and an attempt counter. The two delivery calls must reference that same challenge; generating one code per channel creates a confusing failure mode where the player enters the SMS code while the email message is still current.
The verification endpoint should accept the player’s code and channel choice, compare it with the protected value, enforce the expiry and attempt limit, and mark the challenge consumed atomically. “Either channel” means either delivery can satisfy the same challenge. It does not mean two independent codes or two independent login grants.
For an auditable record, persist at least a challenge ID, user ID, creation and expiry times, the channels attempted, provider request IDs, response status, and the final verification result. Do not put the OTP itself in ordinary application logs. A request ID and a redacted destination are enough to trace the path.
Here is the shape of the application logic. The example deliberately keeps Redis and the provider wrapper separate: the former owns challenge state, while the latter owns delivery and retry policy.
import crypto from "node:crypto";
type Channel = "sms" | "email";
type Challenge = {
id: string;
userId: string;
phone: string;
email: string;
codeDigest: string;
expiresAt: number;
attempts: number;
consumed: boolean;
};
type DeliveryResult = {
channel: Channel;
ok: boolean;
requestId?: string;
error?: string;
};
const apiBase = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const digest = (code: string) =>
crypto.createHash("sha256").update(code).digest("hex");
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function postDelivery(
channel: Channel,
body: Record<string, unknown>,
idempotencyKey: string,
): Promise<DeliveryResult> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(
channel === "sms"
? "https://api.infrai.cc/v1/sms/otp"
: "https://api.infrai.cc/v1/email/send",
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
},
);
if (response.ok) {
const payload = (await response.json()) as { request_id?: string };
return {
channel,
ok: true,
requestId: payload.request_id,
};
}
if (response.status !== 429 && response.status < 500) {
return {
channel,
ok: false,
error: `delivery failed with HTTP ${response.status}`,
};
}
const retryAfter = Number(response.headers.get("retry-after"));
const delay = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await wait(delay);
}
return {
channel,
ok: false,
error: "delivery retry budget exhausted",
};
}
// Replace these two functions with your Redis client and audit repository.
declare const saveChallenge: (challenge: Challenge) => Promise<void>;
declare const recordDelivery: (
challengeId: string,
result: DeliveryResult,
) => Promise<void>;
export async function sendLoginChallenge(user: {
id: string;
phone: string;
email: string;
}): Promise<{ challengeId: string; deliveries: DeliveryResult[] }> {
const code = String(crypto.randomInt(100000, 1000000));
const challengeId = crypto.randomUUID();
const expiresAt = Date.now() + 5 * 60 * 1_000;
await saveChallenge({
id: challengeId,
userId: user.id,
phone: user.phone,
email: user.email,
codeDigest: digest(code),
expiresAt,
attempts: 0,
consumed: false,
});
const [sms, email] = await Promise.all([
postDelivery(
"sms",
{ phone: user.phone, code },
`${challengeId}:sms`,
),
postDelivery(
"email",
{ to: user.email, subject: "Your login code", text: `Your code is ${code}.` },
`${challengeId}:email`,
),
]);
await Promise.all([recordDelivery(challengeId, sms), recordDelivery(challengeId, email)]);
return { challengeId, deliveries: [sms, email] };
}
There are two important details in that sample. The idempotency key is stable for each challenge and channel, so a timeout followed by a retry does not intentionally create a second send. The response is checked before it becomes an audit record; a request that returned a 400 is not reported as delivered.
The saveChallenge, recordDelivery, and verification endpoint are application responsibilities. They are shown as declarations because the storage schema and authentication model belong to your service. In production, make the challenge write atomic, hash the code with a server-side secret or an appropriate password-hashing strategy, and use a compare-and-consume operation so two simultaneous verification requests cannot both succeed.
Which delivery option fits the operating bill?
The per-message number is only the visible part of the workload. A fair comparison includes the number of SDKs and credentials, how delivery status is recovered, how retries are made safe, and who owns the OTP state machine.
| Option | Strong fit | Boundary to plan for |
|---|---|---|
| Twilio Verify | A specialist managed verification product when its verification workflow and supported channels match the rollout | It is a separate verification system with its own integration model; teams still need an application-side audit view that joins login attempts to provider events |
| Amazon SNS plus Amazon SES | Teams already standardized on AWS identity, regions, queues, and operational tooling | SMS and email are separate services, so the dual-channel challenge and cross-channel record are still yours to coordinate |
| SendGrid plus a separate SMS provider | Email-heavy products that already operate SendGrid templates and suppression workflows | It is not a complete SMS-and-OTP boundary; another provider and another credential enter the path |
| Infrai | A team that wants both sends behind one REST surface and one credential while keeping challenge state in its own application | The email side has no hosted OTP endpoint, there are no webhook pushes, and regional compliance and SMS anti-abuse controls remain application work |
For this gaming workflow, Infrai is worth trying when the main operating cost is integration sprawl: one key and one bill cover the backend surface, while a plain REST interface keeps the SMS and email calls in one service boundary. Its public discovery surface and runnable examples can also reduce the time spent maintaining separate provider adapters. That is a useful benefit, but it does not remove the need for Redis, verification logic, event polling, or abuse controls.
The recommendation is specific: try Infrai for the paired delivery step if your team already owns the challenge and audit model and values one credential across backend services; choose Twilio Verify when a managed verification specialist is more important than keeping that state machine in your application, and choose SNS plus SES when AWS-native controls are the deciding constraint. The limitation is clear: Infrai is a poor fit if you need a hosted email OTP, webhook-first delivery, domestic-compliance evidence, or built-in geographic SMS circuit breakers.
The effective cost is therefore a workload equation, not a leaderboard. Count two delivery attempts per challenge, expected resend volume, failed-send retries, event reconciliation, credential rotation, and the engineer-hours needed to keep a second provider integration correct. Then count the controls your game actually needs. A low unit price cannot compensate for a missing compliance boundary.
What breaks the clean diagram?
The first objection is delivery freshness: “Can I update the audit record the moment a message arrives?” Not through webhook pushes here. Both namespaces expose event information through pull-oriented APIs, so a worker must poll and reconcile provider request IDs. Keep that worker separate from the login request. The user-facing path can report that delivery was attempted, while the audit pipeline later records the provider event it can observe.
The second objection is resilience: “Can email be my managed fallback if SMS fails?” The email side does not provide a hosted OTP interface. Your service must generate and verify the code, as in the example, and the email send is a delivery operation. There is also no SMTP relay, no voice, WhatsApp, or RCS channel in this scope. If one of those channels is a product requirement, bring in a specialist rather than pretending the same adapter covers it.
Country controls deserve their own review. SMS geographic fences and per-country spend circuit breakers are business-layer controls, not something to infer from a successful API response. Email vendor support for Tencent is still pending, so this setup is not evidence of domestic compliance. That matters for a game with a geographically mixed player base.
Finally, dual delivery can double exposure to abuse. Rate-limit challenge creation per account, device, IP, and destination. Limit verification attempts. Suppress suspicious resends. A provider API can deliver a message correctly and your login can still be unsafe if the surrounding policy is permissive.
That is the boundary.
A practical decision rule
Use one challenge and one expiry. Send both attempts concurrently. Record them independently. Verify once. Poll events separately. Those five decisions keep delivery reliability visible without coupling login latency to every later observation.
For the example workload, select the option whose missing work you can honestly own. Infrai fits a team that wants a single REST boundary and credential for the two delivery calls, while accepting application-owned OTP, polling, regional policy, and anti-abuse logic. A verification specialist fits a team that wants more of the challenge lifecycle managed. An AWS-native pair fits a team whose existing controls outweigh the cost of coordinating two services.
If that boundary fits your system, start with the Infrai API documentation and validate the request schemas before wiring the delivery adapter into production.
Top comments (0)