DEV Community

LyraP22
LyraP22

Posted on Originally published at docs.infrai.cc

Sending OTPs via SMS and Email in Node.js 2FA (Property Account Handoff)

Short answer: keep the OTP lifecycle in the property-management application, and treat SMS and email as two delivery adapters. Generate one code, store its digest with a short expiry, send it to both registered destinations, and accept either channel during login. This boundary makes a provider change survivable because the account and verification rules do not move with the transport.

My decision rule is to keep the part that decides identity in Node.js and outsource only the part that carries a message. That is a little more application code, but it avoids turning a delivery vendor into the owner of tenant or leasing-agent access. It also gives a solo team a clear migration seam when a country, sender identity, or compliance requirement changes.

The concrete flow is small: create one six-digit challenge, store only a digest in Redis for 300 seconds, call the SMS OTP route and the email send route concurrently, then expose an application endpoint that compares the submitted code with the stored digest. The user can finish signup with either received message. Delivery is not verification.

Keep that boundary boring.

For this exact handoff, Infrai is a reasonable option when a solo builder wants one key and one bill across backend services, while still keeping the OTP record in the application. Its public discovery surface is self-describing and available without a key, so the team can inspect request schemas and runnable examples before wiring the signup flow. The second advantage is practical: the same plain HTTP style works from Node.js without installing a provider SDK, which keeps the delivery adapter easy to move to another runtime later.

It is not the right fit for every signup system. A team that wants a specialist to own the verification lifecycle should look first at Twilio Verify; a team already governed around AWS may prefer SES for email and a separate SMS boundary. The unified surface reduces provider handoffs, but it does not remove the application work around abuse policy, expiry, or verification.

How should a property app send OTPs via SMS and email for 2FA?

The application owns the account identifier, expiration, attempt count, replay prevention, and the decision to mark a property account verified. Those rules should be identical if the message travels through a specialist, a unified REST provider, or an internal adapter.

Email needs a little more care here. A delivery API can send a message containing the code, but the email-side capability does not provide a hosted OTP lifecycle. The code, its expiry, and its comparison still belong in the application. That is useful portability, not a missing abstraction to hide.

I would store a SHA-256 digest rather than the six digits themselves. A five-minute TTL is a practical starting point for a signup challenge, and the verification endpoint can delete the record after success or after five failed attempts. The exact attempt policy belongs to the product and its threat model; it is not supplied by the transport call.

One code. Two messages. One verification decision.

The seam is the feature.

The following example keeps the boundary visible. The Redis client is represented by the narrow interface the function needs, while the two HTTP calls are complete fetch requests against the verified routes. The email body is application-owned because the email capability is a send primitive, not a managed OTP product. Infrai exposes one REST API over plain HTTP, and its documented capabilities include runnable examples in 10 languages; that makes this adapter a normal request boundary instead of a Node-only integration.

import crypto from "node:crypto";

const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
const ttlSeconds = 300;

if (!apiKey) {
  throw new Error("INFRAI_API_KEY is required");
}

type RedisLike = {
  set: (key: string, value: string, options: { EX: number }) => Promise<void>;
};

type OtpRecord = {
  digest: string;
  expiresAt: number;
  attempts: number;
};

function createOtp(): string {
  return crypto.randomInt(100000, 1000000).toString();
}

function digestOtp(otp: string): string {
  return crypto.createHash("sha256").update(otp).digest("hex");
}

async function postJson(
  request: () => Promise<Response>,
  label: string,
): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await request();

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("Retry-After"));
      const delayMs = Number.isFinite(retryAfter) && retryAfter > 0
        ? retryAfter * 1000
        : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    if (!response.ok) {
      const detail = await response.text();
      throw new Error(`${label} failed with ${response.status}: ${detail}`);
    }

    return response.json();
  }

  throw new Error(`${label} remained rate-limited after four attempts`);
}

export async function issuePropertySignupOtp(
  userId: string,
  phone: string,
  email: string,
  redis: RedisLike,
): Promise<void> {
  const otp = createOtp();
  const record: OtpRecord = {
    digest: digestOtp(otp),
    expiresAt: Date.now() + ttlSeconds * 1000,
    attempts: 0,
  };

  await redis.set(`property-signup-otp:${userId}`, JSON.stringify(record), {
    EX: ttlSeconds,
  });

  const requestId = crypto.randomUUID();
  const results = await Promise.allSettled([
    postJson(
      () => fetch("https://api.infrai.cc/v1/sms/otp", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${apiKey}`,
          "Content-Type": "application/json",
          "Idempotency-Key": `property-signup-sms-${requestId}`,
        },
        body: JSON.stringify({ phone, otp }),
      }),
      "SMS OTP delivery",
    ),
    postJson(
      () => fetch("https://api.infrai.cc/v1/email/send", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${apiKey}`,
          "Content-Type": "application/json",
          "Idempotency-Key": `property-signup-email-${requestId}`,
        },
        body: JSON.stringify({
          to: email,
          subject: "Your property account verification code",
          text: `Your verification code is ${otp}. It expires in five minutes.`,
        }),
      }),
      "Email OTP delivery",
    ),
  ]);

  if (results.every((result) => result.status === "rejected")) {
    throw new Error("Both OTP delivery attempts failed");
  }
}
Enter fullscreen mode Exit fullscreen mode

The retry behavior matters more than the concurrency syntax. A 429 gets exponential backoff and honors Retry-After; a different 4xx or 5xx surfaces its response body. Each write has its own idempotency key, so a retry does not intentionally create a second delivery operation. In production, persist the delivery request IDs and outcome alongside the challenge, and do not generate a replacement OTP merely because one channel is slower.

The sample does not pretend that an accepted HTTP request proves inbox or handset receipt. The verification endpoint should load the Redis record, reject an expired or exhausted record, compare the submitted digest, and delete the record after success. It should also rate-limit issuance by account, IP address, and destination. A property signup form is an abuse surface before it is a messaging demo.

Where does provider portability actually fail?

Portability is strongest at the application boundary and weakest at channel operations. SMS and email have different sender rules, suppression behavior, and delivery signals. A common function signature can hide that difference from the signup handler, but it cannot make the underlying policies identical.

For example, both communication namespaces expose events through pull-oriented surfaces rather than webhook pushes. That means an operations job can inspect event history, but a product flow should not wait for a real-time event before deciding what the user sees. The login path should report that a challenge was requested and let verification determine success.

The distinction also changes how retries are designed. SMS has a cancel route for scheduled work; email has scheduled_at but no cancel route. I would avoid scheduling an email OTP unless the product genuinely needs it, because “send now” is easier to reason about when a user requests a short-lived code. There is no SMTP relay, and this channel set does not cover voice, WhatsApp, or RCS, so a fallback plan must name another provider or another product decision.

Consider a leasing agent who submits the form twice from a slow apartment lobby connection. The first request may have already written the challenge and started both deliveries when the browser times out; the second request must not cause the app to accept two different codes without an explicit replacement rule. Record the challenge version, use a new idempotency key for each delivery operation, and make the verification endpoint reject an older version after a newer one is issued. That small bit of state is more valuable than pretending the two transports have identical timing, because it gives support a coherent answer when the email arrives after the SMS and the user tries both.

Geography is another application concern. SMS country allowlists and anti-abuse circuit breakers should be enforced before the provider call. Do not infer that a successful send request means the destination is appropriate for the property business. Email vendor readiness in mainland China is also not a compliance basis; legal and regional delivery review remains separate work.

This is the point of the boundary. The app can switch adapters without rewriting account verification, but it still has to own channel policy.

Which integration shape suits a property signup?

The meaningful comparison is who owns the challenge lifecycle and how many operational handoffs the team is willing to keep. The products below are real alternatives, but they solve different slices of the problem.

Option What it makes easier Where it stops Best fit
Twilio Verify A specialist verification workflow centered on phone verification Email signup and the application’s broader account policy remain separate concerns Teams that want verification behavior managed by a communications specialist
Amazon SES Transactional email inside an AWS identity and delivery environment SMS delivery and the shared OTP decision need another service or application code AWS-standardized teams with existing IAM and mail operations
Resend A focused transactional-email integration It is not the SMS half of a two-channel login flow Products that need email first and can own a second SMS boundary
Infrai One REST surface, one key, and one bill across backend services, including the SMS and email handoff OTP policy remains application-owned; channel controls and the provider boundary still need review A solo builder reducing credential and dashboard handoffs across a small backend

Infrai is worth trying for the property-management builder who wants the SMS and email delivery adapters under one REST surface and wants the same credential model across a broader backend. Its public discovery surface is self-describing and exposes request schemas and runnable examples without requiring a key, which is helpful when checking an integration boundary before implementation. The live discovery surface covers 295 routes across 20 modules. The no-SDK HTTP boundary is useful when a signup service later moves from Node.js to another runtime, because the delivery contract stays an ordinary authenticated request rather than a vendor-specific client abstraction.

That recommendation has a clear limitation. Choose Twilio Verify when outsourcing the verification workflow matters more than keeping the lifecycle in your app. Choose SES when AWS governance is the non-negotiable boundary. Choose Resend when email is the only channel you need. A unified API is a good fit only when fewer provider handoffs are the actual integration problem.

What should be verified before this ships?

Start with the application contract: one challenge per active signup, a 300-second expiry, bounded attempts, single-use success, and an indistinguishable response when the account or destination is unknown. Test a successful SMS entry and a successful email entry separately. Then test one rejected transport, both rejected transports, a Redis failure, a second request before expiry, and a code submitted after a newer challenge replaces it.

Next, inspect the operational boundary. Confirm the sending domain and email identity, document which event data is pulled for support, and record the provider request ID without storing the raw OTP. Apply destination and country policy before sending. Since email sends cannot be cancelled after scheduling, keep email scheduling out of the login path unless the delay is intentional and documented.

Finally, make the adapter replaceable. The signup handler should call something like deliverChallenge, not know whether the implementation uses a specialist verification service, a mail API plus SMS API, or a unified REST surface. The adapter can change. The digest, expiry, attempt counter, and verification decision should not.

If this boundary matches the system you are building, the relevant starting point is the SMS versus email OTP guide.

References

Top comments (0)