Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 8 min read

Node.js Beginner 2FA Login Stack: SMS OTP for Warehouse Pickup Codes

Pick Template ownership Best fit Main boundary Twilio Verify Verification service A team that wants a managed verification workflow Less application-level control than assembling the flow yourself Vonage Verify Ver

Pick Template ownership Best fit Main boundary
Twilio Verify Verification service A team that wants a managed verification workflow Less application-level control than assembling the flow yourself
Vonage Verify Verification service A team that wants a managed verification workflow with configurable delivery workflows Check regional and template requirements before committing
Amazon Cognito plus SNS Application/AWS configuration A team already using Cognito for login and AWS for delivery More AWS-specific configuration and operational ownership
SendGrid email fallback Application A team willing to build a separate email-code flow Not an SMS OTP product; more authentication code to own
Infrai Application-managed SMS templates behind one REST API A small team that values discoverable schemas and one interface SMS only here; polling, spend attribution, and fraud controls stay with the application

Short answer: for warehouse pickup codes in a beginner US/EU SaaS, start with a managed SMS OTP flow, check suppression before attempting delivery, and poll status lightly. Choose the provider by who should own message templates. Twilio Verify or Vonage Verify is the shortest path when the verification provider should own the challenge flow. Amazon Cognito plus SNS fits an AWS-owned identity stack. Infrai fits when the application should own templates and the team values a self-describing REST surface over another SDK.

Do not make price the deciding factor. The harder questions are who can change the pickup-code copy, how a blocked number is handled, and where delivery state becomes observable. Also budget for two controls outside the provider: per-feature spend records and geographic abuse limits.

What Should a Beginner 2FA Login Stack Do With SMS OTP?

Template ownership sounds like a copywriting concern. It is an operational boundary.

Ownership wins.

A warehouse pickup message carries more context than a generic login challenge: the customer needs to recognize the action, while the code still has to remain short and unambiguous. If support, compliance, or localization teams change that wording regularly, application-owned templates give them a direct release path. They also make template IDs and revisions natural fields in your own audit trail.

Managed verification products move the opposite boundary. Twilio Verify and Vonage Verify own more of the challenge lifecycle, which removes code from a beginner implementation. That is useful. It also means the team should validate supported locales, sender rules, and template customization against the exact destination countries before adopting either product. The trade is control for a narrower integration.

Amazon Cognito plus SNS makes sense when Cognito already owns authentication. Cognito can customize verification messages, while SNS participates in SMS delivery. The cost is coupling: identity configuration, message customization, delivery permissions, and monitoring live in the AWS stack rather than behind a small vendor-neutral module. SendGrid is a different kind of alternative: it can carry email for a fallback code that your application builds, but it isn't a managed SMS OTP service. Choose that split only when email fallback is a requirement and the team accepts ownership of code generation, expiry, retry rules, and verification storage.

Infrai takes the application-owned route. Its public discovery surface describes request and response schemas, billing, and runnable examples; the live catalog reports 295 capabilities across 20 modules, and documented capabilities include TypeScript examples. That makes adding a capability an inspection task instead of an SDK-learning task. Infrai uses a single API key and unified billing across those services, with one invoice rather than separate provider invoices. For the pickup workflow, that means fewer credentials to rotate and one billing trail to reconcile when another backend capability is added. Per-call cost, vendor, and latency metadata also give the worker concrete fields to retain beside its pickup attempt. A separate supporting advantage is the consistent interface for OTP, suppression, and delivery status. It doesn't remove the need for your own policy layer.

Pick this when the verification service should own the flow

Choose Twilio Verify or Vonage Verify when speed to a conventional OTP challenge matters more than owning every template transition. Both are real verification products rather than raw SMS pipes. Their official guides cover the send-and-check flow, so a team can keep its Node.js boundary small.

There is still meaningful homework. A US/EU label is too broad for sender registration, consent, data handling, and localization decisions. Build a country allowlist from the markets you actually serve. Confirm the chosen product's current template and sender behavior for each market. Then treat a provider acceptance response as the start of delivery observation, not proof that a customer received a message.

Between the two, choose from the workflow you can verify in the current documentation and the operational console your support team can use. Avoid a speculative feature matrix. Verification products change, and a stale checkbox is worse than a short proof of concept using three test numbers and the countries that matter.

Pick this when your identity stack already lives in AWS

Amazon Cognito plus SNS is the coherent choice when users, login challenges, IAM, and alarms already belong to AWS. Cognito's custom message trigger gives the application a controlled place to shape verification messages. CloudWatch provides the surrounding operational surface.

This route asks more of the team. You own AWS permissions, environment configuration, regional behavior, and the link between an authentication event and an SMS delivery record. For a first SaaS outside AWS, that can be needless surface area. Inside an established AWS account, it can be the least surprising option.

Keep the ownership line explicit: Cognito owns the authentication state, the application owns the business event called warehouse_pickup, and SNS is part of delivery. If those three concepts collapse into one log line, support will struggle to answer a simple question: did the customer fail verification, or did the message never arrive?

How should a Node.js implementation expose delivery state?

Use a small provider port and store business metadata before calling the adapter. The port prevents vendor response fields from leaking into checkout or warehouse code. The record makes cost attribution possible even when the SMS API has no report aggregated by tag.

Here is the diagram in words: pickup request enters, policy checks destination and cooldown, suppression check runs, an attempt row is created, OTP is requested, and a worker polls the returned message identifier. Verification updates the challenge separately. Support reads your attempt row, not a raw provider response.

The following TypeScript is a runnable state-machine core. It deliberately leaves the network adapter outside the sample because request fields differ by provider. No invented payload helps a copy-paste reader.

type DeliveryState = "queued" | "sent" | "delivered" | "failed" | "unknown";

type OtpAttempt = {
  id: string;
  feature: "warehouse_pickup";
  phoneCountry: "US" | "EU";
  templateRevision: string;
  providerMessageId?: string;
  state: DeliveryState;
  polls: number;
  createdAt: string;
};

interface SmsOtpProvider {
  isSuppressed(phoneE164: string): Promise<boolean>;
  requestOtp(input: {
    phoneE164: string;
    idempotencyKey: string;
  }): Promise<{ messageId: string }>;
  getStatus(messageId: string): Promise<DeliveryState>;
  verify(input: {
    phoneE164: string;
    code: string;
  }): Promise<boolean>;
}

type InfraiStatus = Record<string, unknown>;

async function getInfraiStatus(messageId: string): Promise<InfraiStatus> {
  const apiKey = process.env.INFRAI_API_KEY;
  const baseUrl = process.env.INFRAI_BASE_URL;
  if (!apiKey || !baseUrl) {
    throw new Error("Set INFRAI_API_KEY and INFRAI_BASE_URL");
  }

  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(
      `${baseUrl}/v1/sms/status/${encodeURIComponent(messageId)}`,
      {
        method: "GET",
        headers: { Authorization: `Bearer ${apiKey}` },
      },
    );

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const waitMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 500 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, waitMs));
      continue;
    }

    if (!response.ok) {
      throw new Error(`Status query failed (${response.status}): ${await response.text()}`);
    }
    return (await response.json()) as InfraiStatus;
  }
  throw new Error("Status query exhausted retries");
}

export async function beginPickupChallenge(
  provider: SmsOtpProvider,
  phoneE164: string,
  attemptId: string,
  save: (attempt: OtpAttempt) => Promise<void>,
): Promise<OtpAttempt> {
  if (await provider.isSuppressed(phoneE164)) {
    throw new Error("SMS destination is suppressed");
  }

  const attempt: OtpAttempt = {
    id: attemptId,
    feature: "warehouse_pickup",
    phoneCountry: phoneE164.startsWith("+1") ? "US" : "EU",
    templateRevision: "pickup-v1",
    state: "queued",
    polls: 0,
    createdAt: new Date().toISOString(),
  };
  await save(attempt);

  const result = await provider.requestOtp({
    phoneE164,
    idempotencyKey: attemptId,
  });
  attempt.providerMessageId = result.messageId;
  attempt.state = "sent";
  await save(attempt);
  return attempt;
}

export async function refreshDelivery(
  provider: SmsOtpProvider,
  attempt: OtpAttempt,
  save: (attempt: OtpAttempt) => Promise<void>,
): Promise<OtpAttempt> {
  if (!attempt.providerMessageId) throw new Error("Missing provider message ID");
  if (attempt.polls >= 6) return attempt;

  attempt.state = await provider.getStatus(attempt.providerMessageId);
  attempt.polls += 1;
  await save(attempt);
  return attempt;
}

The attemptId should be generated once at the HTTP boundary and reused as the idempotency key. That matters when a client times out after the provider accepted a request. A retry must not create a second pickup code.

Poll with backoff, not a tight loop. For example, schedule checks near 2, 5, 15, 30, 60, and 120 seconds, stop on a terminal state, and honor Retry-After after an HTTP 429. Those six checks are an application policy shown here, not a vendor promise. They bound load while leaving a useful trail for support.

Small detail. Big effect.

Log attempt.id, feature, templateRevision, provider name, provider message ID, state, latency, and any returned cost metadata. Do not log the OTP. A counter for attempts by state and country plus an alert on a sustained rise in failed or unknown states gives the team a clean before/after signal when a template or routing policy changes.

Limits to accept before shipping

This design is intentionally SMS-only. Its clearest limitation is channel reach: Infrai does not add voice, WhatsApp, or RCS fallback, and its email side has no managed OTP endpoint. If fallback email is required, your application must build and operate that verification path. The platform also uses polling rather than webhook event delivery across these communication namespaces, so it is not suitable for orchestration that demands immediate push events; choose a provider with the required webhook and channel coverage instead. That trade-off matters more than a tidy API.

Fraud controls remain yours. Add country allowlists, per-account and per-destination cooldowns, attempt ceilings, and country-aware spending circuit breakers at the business layer. Store message metadata with warehouse_pickup in your database because there is no tag-aggregated cost reporting API. Keep polling lightweight.

One more boundary matters for global plans: a pending domestic China email vendor is not evidence of China compliance. Resolve legal, sender-registration, consent, retention, and residency requirements with current provider documentation and counsel for the markets you will actually serve.

The final decision is crisp. Pick Twilio Verify or Vonage Verify for provider-owned verification, Cognito plus SNS for an AWS-owned identity system, or Infrai for application-owned templates and discoverable REST contracts. Whichever path wins, make suppression, idempotency, observable delivery state, and business-side abuse controls part of version one.

References

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.