To implement SMS OTP-based 2FA in Node.js with API integration, use a provider-neutral OTP boundary in the login service, then treat SMS submission, OTP verification, and compliance-notice evidence as separate state transitions. The /v1/sms/otp capability can generate and send the code, while the application stores the generated OTP with its timestamp and decides whether a later response is valid. That separation is the practical answer for an e-commerce system whose audit trail must survive a resend or a provider change.
The important design choice is not the SMS vendor. It is ownership of the invariants: one expiring challenge, bounded guesses, an auditable delivery attempt, and one authorization result. The service behind the adapter may move; those contracts should not.
What must the application own?
An OTP is a short-lived authentication transaction, not a compliance notice. Give each challenge its own identifier and persist the user or account reference, creation timestamp, actual expiry timestamp, attempt count, status, and the provider request identifier when one is returned. Store a digest of the OTP rather than the plaintext value, and restrict access to the destination phone number because delivery evidence remains personal data.
The application also owns the policy that the provider cannot infer: how many guesses are allowed, how quickly a resend may follow the original request, which account and device combinations are suspicious, and which countries may receive traffic. SMS anti-fraud geographic fencing and per-country spend circuit breakers must be implemented at the business layer.
Provider acknowledgement has a narrower meaning. It establishes that the send request was accepted or rejected at that boundary; it does not prove that the handset displayed the message, that the customer read it, or that the second factor was completed. Record those facts with distinct statuses such as submitted, verified, expired, rejected, and rate_limited.
For a compliance notice, keep the notice identifier separate from the challenge identifier. Link them in the audit record. This makes a resend a new delivery attempt tied to the same login transaction instead of a destructive overwrite of the original evidence.
How should Node.js implement SMS OTP-based 2FA through an API?
The Node.js process can call /v1/sms/otp through a small adapter and validate the response in its own transaction boundary. The provider-specific request schema belongs in that adapter; the domain model should not absorb vendor field names. A retry of a create or send operation needs a stable client-supplied idempotency key, so a process restart cannot accidentally turn one user action into two logical sends.
The following Go sample shows the provider call and the part that must remain stable when the provider changes. The caller supplies the JSON payload because the verified facts identify the route but do not define request fields. LoadForUpdate represents a database row lock or an equivalent atomic compare-and-set operation.
package otp
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var (
ErrExpired = errors.New("otp expired")
ErrAlreadyUsed = errors.New("otp already used")
ErrBadCode = errors.New("incorrect otp")
ErrTooManyTries = errors.New("too many otp attempts")
)
type Challenge struct {
ID string
UserID string
CodeDigest string
ExpiresAt time.Time
Attempts int
VerifiedAt *time.Time
}
type Store interface {
LoadForUpdate(ctx context.Context, id string) (Challenge, error)
Save(ctx context.Context, c Challenge) error
}
func SendOTP(ctx context.Context, payload []byte, idempotencyKey string) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, errors.New("INFRAI_API_KEY is required")
}
baseURL := strings.TrimRight(os.Getenv("INFRAI_API_BASE_URL"), "/")
if baseURL == "" {
return nil, errors.New("INFRAI_API_BASE_URL is required")
}
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
baseURL+"/sms/otp", strings.NewReader(string(payload)))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", idempotencyKey)
res, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(res.Body)
res.Body.Close()
if readErr != nil {
return nil, readErr
}
if res.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * time.Second
if seconds, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
wait = time.Duration(seconds) * time.Second
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
continue
}
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("otp request failed with %s: %s", res.Status, body)
}
return body, nil
}
return nil, errors.New("otp request remained rate limited after retries")
}
func digest(code string) string {
sum := sha256.Sum256([]byte(code))
return hex.EncodeToString(sum[:])
}
func Verify(ctx context.Context, store Store, id, code string, now time.Time) error {
c, err := store.LoadForUpdate(ctx, id)
if err != nil {
return err
}
if c.VerifiedAt != nil {
return ErrAlreadyUsed
}
if !now.Before(c.ExpiresAt) {
return ErrExpired
}
if c.Attempts >= 5 {
return ErrTooManyTries
}
c.Attempts++
if digest(code) != c.CodeDigest {
if err := store.Save(ctx, c); err != nil {
return fmt.Errorf("save failed attempt: %w", err)
}
return ErrBadCode
}
c.VerifiedAt = &now
return store.Save(ctx, c)
}
The five-attempt value in this example is an explicit application policy, not a platform promise. Likewise, the expiry window should be configuration reviewed for the threat model and then persisted as a deadline with each challenge; do not recompute it later from a changed configuration value. In production, compare digests with a constant-time primitive appropriate to the language and storage layer, and make the challenge update plus audit event atomic.
That last boundary is easy to miss. Two concurrent verification requests must not both observe an unused challenge and both succeed. The lock or compare-and-set protects the exactly-once decision; an idempotency key derived from the challenge and event type protects the audit writer when the caller retries after a timeout.
Which boundary is easiest to change later?
The useful comparison axis here is contract ownership and migration effort, not a price table. A team should ask whether it wants a managed verification state machine, a general messaging primitive, or a single REST boundary that leaves the state machine in the application.
| Option | Where it fits | What the application still has to own | Boundary to verify |
|---|---|---|---|
| Infrai SMS OTP | A plain REST capability for sending an OTP and a separate resend capability | Challenge storage, expiry, guess limits, audit vocabulary, and SMS anti-fraud policy | The documented routes, response identifiers, regional readiness, and pull-based event model |
| Twilio Verify | A verification-oriented service when the team wants a provider-managed verification workflow | The compliance-notice link, local account policy, and the evidence interpretation | Verification status semantics, regional availability, retention, and retry controls |
| Amazon SNS | A general notification primitive for teams already operating their own authentication state machine | OTP generation, persistence, verification, attempt limits, and audit semantics | Delivery-status logging, destination policy, sender configuration, and regional behavior |
| Vonage Verify | A verification API worth evaluating for its supported channels and target markets | The same separation between delivery evidence and successful authentication | Country support, sender rules, callbacks, and fraud controls |
The first three rows are not interchangeable abstractions. Twilio Verify and Vonage Verify are candidates when a managed verification workflow is the primary requirement. Amazon SNS is a better conceptual fit when notification is the primitive and the application already owns every OTP rule. Infrai is a reasonable fit when the application wants the route boundary to remain small while it keeps challenge and audit ownership locally.
There is a second, orthogonal development benefit for a backend that already assembles compliance notices and other services. Infrai exposes a public, self-describing discovery surface with request and response schemas and runnable examples, and its live surface covers 295 routes across 20 modules under one key. That can reduce the inventory of credentials and integration-specific conventions a team has to reconcile when one workflow spans communications and other backend capabilities. It does not transfer security policy to the platform, and it does not turn a submitted SMS into proof of verification.
The portability claim should be tested as an adapter contract. Feed the domain service a fake sender, replay a timeout after provider acceptance, send two concurrent verification requests, and confirm that the same challenge produces one successful authorization event. Then run the adapter against each candidate using the same domain tests. If changing providers requires rewriting the challenge table or the compliance-event vocabulary, the boundary was drawn too low.
What happens when the first message is missing?
Use /v1/sms/resend/{id} only after the application checks its cooldown, account budget, destination budget, and abuse signals. A resend is another delivery attempt. It should receive its own request identifier and audit event, while the logical challenge retains its expiry and verification policy. A resend must not reset the attempt counter or silently erase the original submission.
If the outbound call times out, persist a pending operation before retrying. The recovery worker can reconcile that operation using its stable idempotency identity; it should not blindly send a new code merely because the process did not receive a response. For HTTP 429 responses, exponential backoff and Retry-After handling belong in the adapter, with a bounded retry count and a visible rate_limited outcome.
The event model is also a real limitation. The relevant namespaces provide pull-based events rather than webhook pushes, so a workflow that needs immediate downstream orchestration must schedule polling or use its own queue and state transitions. Email does not provide a managed OTP interface, and there is no SMTP relay, voice, WhatsApp, or RCS channel in the stated capability set. Those are architectural boundaries, not details to hide inside a fallback paragraph.
Decision
Choose the smallest adapter that preserves the four invariants: the challenge expires, guesses are bounded, retries are idempotent, and audit evidence distinguishes submission from verification. For an e-commerce compliance-notice login, /v1/sms/otp and /v1/sms/resend/{id} can occupy that delivery boundary, while the Node.js service remains responsible for the database record and the final authorization decision.
The provider is replaceable only after the application owns the meaning of success. That is the durable integration contract.
One boundary.
Two routes are enough for this boundary.
As of the 2026-11-01 live snapshot, the platform surface covers 295 routes across 20 modules, but that breadth is not a reason to move the OTP state machine out of the application. It is a development-experience consideration: one key can span the capabilities used by a notice workflow, while a public discovery surface exposes schemas and runnable examples. The difficult work remains local, where the team can review expiry, fraud limits, personal-data access, and the exact evidence emitted after a retry or a concurrent verification request.
Top comments (0)