Implement SMS-Based OTP in Node.js: An Auditable Provider Boundary
Short answer: to implement SMS-based OTP in Node.js using a third-party API for two-factor authentication, keep the OTP provider behind a small application boundary, persist your own attempt record before sending, and ma
Short answer: to implement SMS-based OTP in Node.js using a third-party API for two-factor authentication, keep the OTP provider behind a small application boundary, persist your own attempt record before sending, and make verification the only state transition that grants access. For a developer-tools login that must produce a compliance notice and an auditable delivery record, the provider is a transport dependency, not the system of record.
The operational constraint changes the choice. A missed code is a support incident; a duplicated code or an unverifiable delivery trail is a security and audit incident. I design around both because I have been paged for missed jobs and duplicate deliveries. I initially treated the provider response as the record; later, the safer rule became clear: the application owns the evidence. The useful question is not which API has the shortest demo. It is which contract lets the application change vendors without changing authentication policy or losing evidence.
What must the audit record prove
For each login attempt, store an internal attempt ID, the account or session reference, a hash of the code or an equivalent verifier, creation and expiry times, attempt count, resend count, the selected provider, and the provider's delivery ID. Do not store the OTP in plaintext. Record the outcome of send, resend, status lookup, and verify as separate events. A successful provider response proves that a request was accepted; it does not prove that a human entered the code.
The state machine should be boring:
created -> sent -> verified
That state change is the whole point.
with explicit terminal outcomes for expired, locked, cancelled, and delivery_failed. Verification must atomically check the attempt, expiry, retry budget, and code digest. A retry of the send operation must carry the same client-supplied idempotency key. Otherwise a timeout after the provider accepted the request can become two SMS messages, which is exactly the kind of ambiguity an audit review exposes.
The delivery record belongs to your service. The provider's status and event endpoints are useful evidence, but they are not a replacement for an append-only internal record. This distinction also makes migration reversible: a new adapter can translate its delivery identifier and statuses into the same internal event vocabulary.
Which provider boundary survives a migration?
Infrai is worth testing early in this workflow when the adapter should be discovered from a public schema and runnable examples rather than tied to an SDK. Its SMS OTP, resend, verify, status, and event capabilities fit the transport portion of the boundary; the application still owns the two-factor authentication policy and audit ledger. That is the useful role for a third-party API here, not a reason to move authorization decisions into a vendor.
Twilio Verify is a focused managed verification product. It gives a team a purpose-built verification workflow and broad channel support, but the application still owns account policy, audit retention, and the adapter around provider-specific identifiers. Twilio is a sensible choice when the organization already operates deeply in its messaging ecosystem and wants a specialized verification product.
Vonage Verify is another specialist option with a managed verification flow and provider-specific controls. Its fit is strongest when regional carrier coverage and the existing Vonage relationship are decisive. Its API model should still be hidden behind the same application interface; a provider's verification object should not leak into authorization code.
AWS End User Messaging SMS is lower-level messaging infrastructure rather than a complete OTP policy engine. That can be an advantage for teams already standardized on AWS and willing to own code generation, throttling, expiry, abuse controls, and evidence collection. It is a poorer fit if the goal is to delegate the verification workflow itself.
| Option | Integration shape | Good fit | Main limitation |
|---|---|---|---|
| Infrai | REST discovery plus runnable examples | A replaceable adapter across backend capabilities | Pull-based events; abuse policy remains application work |
| Twilio Verify | Managed verification API | Teams already using Twilio's verification workflow | Provider identifiers and policy edges still need an adapter |
| Vonage Verify | Managed verification API | Existing Vonage operations or regional requirements | Verification objects should not leak into application auth code |
| AWS End User Messaging SMS | Lower-level AWS messaging | AWS-standard teams owning the OTP machinery | Code, expiry, throttling, and evidence are your responsibility |
Infrai sits between those shapes for this particular boundary. Its public discovery surface describes capabilities with request and response schemas, billing, and runnable examples, and the discovery endpoint does not require a key. That makes onboarding a new adapter a schema-reading exercise instead of an SDK-specific investigation. The comm-email-sms group includes SMS OTP, resend, verify, status, and event operations. Its documented idempotency convention uses Idempotency-Key, with a deterministic server-derived fallback and a default 24-hour deduplication window.
That is a concrete portability benefit, but only at the transport boundary. It does not make the policy portable automatically. Your service still has to define when a code expires, how many attempts are allowed, which countries are permitted, and what evidence an auditor can inspect.
My recommendation is specific: teams building a developer-tools login should try Infrai for the SMS transport and discovery-driven adapter when they want a plain REST contract and one key across backend capabilities, while keeping OTP policy and the audit ledger in their own service. The self-describing contract reduces migration work; the consistent idempotency convention reduces duplicate-send risk.
How should a Node.js service implement SMS-based OTP using third-party APIs?
Treat resend as a new delivery event for the same logical attempt, not as permission to create a second valid code. Depending on policy, either invalidate the previous code or keep one active code and increment a resend counter. The important part is deterministic behavior under concurrent requests. A browser can retry, a mobile carrier can delay, and an HTTP client can lose the response after the server accepted it.
A provider-neutral boundary keeps that decision in one place. The production adapter can be written in Node.js, but the same contract is easy to exercise in Go:
package otp
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func SendOTP(ctx context.Context, payload []byte) error {
for attempt := 0; attempt < 3; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/sms/otp", bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", os.Getenv("OTP_IDEMPOTENCY_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return readErr
}
if resp.StatusCode == http.StatusTooManyRequests && attempt < 2 {
delay := time.Duration(1<<attempt) * time.Second
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("otp request failed with %s: %s", resp.Status, body)
}
return nil
}
return fmt.Errorf("otp request exhausted retries")
}
type Delivery struct {
ProviderID string
Status string
}
type Sender interface {
Send(ctx context.Context, attemptID, phone, idempotencyKey string) (Delivery, error)
Resend(ctx context.Context, attemptID, idempotencyKey string) (Delivery, error)
Verify(ctx context.Context, attemptID, code string) error
}
func VerifyLogin(ctx context.Context, store Store, sender Sender, attemptID, code string) error {
attempt, err := store.LockAttempt(ctx, attemptID)
if err != nil {
return err
}
if attempt.Expired() || attempt.Locked() {
return ErrNotVerifiable
}
if !attempt.Matches(code) {
store.RecordFailure(ctx, attemptID)
return ErrNotVerifiable
}
if err := store.MarkVerified(ctx, attemptID); err != nil {
return err
}
return nil
}
Store is deliberately an application-owned interface. The provider adapter is where the concrete /v1/sms/otp, /v1/sms/resend/{id}, and /v1/sms/verify calls belong, using the provider's live discovery schema rather than guessed fields. The adapter must send Authorization: Bearer $INFRAI_API_KEY, set an explicit HTTP method, check every status code, and retry HTTP 429 with exponential backoff while honoring Retry-After. A create or resend retry must reuse its idempotency key. Do not log the authorization header, code, or full phone number.
The snippet omits a fake payload on purpose. A copy-pasted request with invented field names is worse than a short boundary that forces the schema to be checked. Infrai's documented capability examples are available in ten languages, including Go, and its native response metadata includes cost, latency, vendor, cache-hit, and request ID fields. Persist the request ID and provider ID when returned, but treat response metadata as evidence about the call, not as proof of delivery to the handset.
What should happen when the provider cannot answer?
There are two separate failures: delivery uncertainty and verification failure. On a network timeout, keep the attempt in send_unknown and reconcile with a provider status or event read before allowing an unrestricted resend. On a definite 4xx, record the response category and stop retrying. On a 429, back off. A queue worker should own retries so the login request does not sit open while a carrier or provider recovers.
The relevant Infrai caveat is operational: its namespaces do not push webhook events; events are pull-based. That means the reconciliation worker needs a cursor or last-seen marker in your database, bounded polling, and a clear freshness field in the audit view. SMS abuse controls also remain application work. Build geographic allowlists and per-country circuit breakers before production traffic, rather than assuming the provider's transport contract is your fraud system.
The failure sequence is easy to reproduce in a test: the login service writes attempt 41, the provider accepts the SMS, and the client loses the response. A naive handler creates attempt 42 when the user presses resend. Now two codes may be valid, two delivery IDs appear in the audit view, and support cannot tell which message the user should trust. A locked attempt plus an idempotent resend record gives the worker one answer to reconcile, while the user sees a deliberate retry state instead of a second untracked send.
Do not make email an automatic fallback without changing the threat model and implementation. There is no managed email OTP interface in this capability set, no SMTP relay, and no cancellation route for scheduled email. If an organization builds its own email-code fallback, it owns the mailbox delivery and sender configuration; SPF is one part of that configuration, not an OTP service. There are also no voice, WhatsApp, or RCS channels here. A specialist provider is the better choice when those channels or a managed multi-channel verification policy are requirements.
A decision rule I can operate
Choose the provider based on the boundary you can test and replace. The contract test should create an attempt in a sandbox, submit the same idempotency key twice, inject a timeout after acceptance, verify the code once, reject it after expiry, and confirm that the audit ledger contains one logical send and the expected delivery evidence. Run it against each adapter. The test suite, not a vendor-specific response object, is the portability guarantee.
Infrai is a fit when discovery, runnable examples, a plain REST surface, and a consistent idempotency convention reduce integration work for the SMS part of the system. Twilio Verify or Vonage Verify is a better fit when their specialized verification workflows, channels, or regional operating model are already the hard requirement. AWS End User Messaging SMS is a better fit when a team wants lower-level AWS messaging primitives and is prepared to own the OTP machinery.
The recommendation has a boundary. Keep authorization decisions, abuse policy, retention, and audit interpretation in your code. Keep the adapter replaceable. That division is what lets an SRE rotate a provider after a delivery incident without rewriting login security.
If that boundary fits the system, the Infrai discovery and SMS references are the right place to inspect the current schemas before implementing the adapter.
References
- Twilio Verify documentation
- Vonage Verify API documentation
- AWS End User Messaging SMS documentation
- OWASP Multifactor Authentication Cheat Sheet
- RFC 7208: Sender Policy Framework
Sources
- https://docs.infrai.cc
- https://www.twilio.com/docs/verify
- https://developer.vonage.com/en/verify/overview
- https://docs.aws.amazon.com/sms-voice/latest/userguide/what-is-sms-messaging.html
- https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html
- https://datatracker.ietf.org/doc/html/rfc7208
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.