TL;DR: For a cheap, simple 2FA login in a Node.js public-sector appointment app, use a dedicated SMS OTP API rather than direct send. The managed endpoint removes code storage and matching from your application, while your backend still owns expiry UX, resend timers, attempt lockouts, consent, monitoring, and the evidence needed for a compliance review. The same boundary works when a B2B marketplace must authenticate a seller before revealing a new public-sector order.
The bill is mostly message delivery multiplied by sends, not the few bytes in an OTP record. If A is login attempts, r is average sends per attempt after resends, and p is the provider's current per-message charge, the delivery term is A * r * p; audit storage is proportional to compact metadata, not message bodies. Therefore, the useful cost change is reducing unnecessary resends and abuse, rather than retaining less evidence or choosing an API because of a headline unit price.
For a B2B marketplace carrying public-sector orders, the alert and the authentication event are separate records. The alert says that an order exists. The verification record says why access was granted. Mixing them into one generic send SMS abstraction makes reconciliation harder precisely when an auditor asks which policy decision preceded a seller viewing an order.
Should a cheap Node.js 2FA login use an SMS OTP API?
An OTP provider can decide whether a submitted code matches, but it cannot prove that my application applied the right policy. I would persist an append-only decision record containing an internal challenge ID, seller and order references expressed as non-secret identifiers, provider request ID when returned, timestamps, outcome, attempt count, policy version, and a correlation ID. I would not store the OTP, the SMS body, or a bearer token. Less retained secret material narrows the exposure created by the evidence store.
This is an exactly-once mindset applied to business effects, not a claim that the network delivers exactly once. A retry may occur. The order view must still be unlocked once, against one challenge, and the audit write must be deduplicated. Infrai specifies Idempotency-Key as a platform convention with a 24-hour default deduplication window; that makes it a reasonable candidate when a team values one REST contract across a broad backend surface, but the application still needs its own durable uniqueness constraint because compliance retention will outlive that window.
The first integration task is to inspect the live contract instead of guessing field names. The self-describing discovery surface is public, while the program below also accepts the same bearer credential used for authenticated calls. Set INFRAI_BASE_URL to the service's versioned API base and INFRAI_API_KEY to an environment-provided key. It fetches the OTP capability schema, handles 429 with bounded exponential backoff and Retry-After, and surfaces the response body on errors.
package main
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func main() {
baseURL, key := os.Getenv("INFRAI_BASE_URL"), os.Getenv("INFRAI_API_KEY")
if baseURL == "" || key == "" {
panic("INFRAI_BASE_URL and INFRAI_API_KEY are required")
}
client := &http.Client{Timeout: 15 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(http.MethodGet, baseURL+"/discovery/sms.otp", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+key)
res, err := client.Do(req)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(res.Body)
res.Body.Close()
if readErr != nil {
panic(readErr)
}
if res.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
panic(fmt.Sprintf("discovery failed: status=%d body=%s", res.StatusCode, body))
}
fmt.Println(string(body))
return
}
panic("discovery remained rate-limited after four attempts")
}
After the schema has fixed the provider boundary, the next Go program demonstrates the application's evidence boundary without inventing a vendor payload. It records only a hash of the provider request ID and rejects a second terminal decision for the same challenge. In production, Append would be a transaction containing a unique constraint on ChallengeID, and the output would go to a controlled audit sink.
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"sync"
"time"
)
type VerificationDecision struct {
ChallengeID string
SellerRef string
OrderRef string
ProviderRef string
PolicyVersion string
Outcome string
Attempts int
DecidedAt time.Time
}
type Ledger struct {
mu sync.Mutex
entries map[string]VerificationDecision
}
func hashRef(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func (l *Ledger) Append(d VerificationDecision) error {
l.mu.Lock()
defer l.mu.Unlock()
if _, exists := l.entries[d.ChallengeID]; exists {
return errors.New("terminal decision already recorded")
}
d.ProviderRef = hashRef(d.ProviderRef)
l.entries[d.ChallengeID] = d
return nil
}
func main() {
ledger := Ledger{entries: make(map[string]VerificationDecision)}
decision := VerificationDecision{
ChallengeID: "challenge-7f3c",
SellerRef: "seller-1842",
OrderRef: "order-9031",
ProviderRef: "provider-request-reference",
PolicyVersion: "seller-login-v3",
Outcome: "verified",
Attempts: 1,
DecidedAt: time.Now().UTC(),
}
if err := ledger.Append(decision); err != nil {
panic(err)
}
fmt.Println("recorded", decision.ChallengeID)
}
Keep it sparse.
The retention schedule should come from the applicable public-sector contract, records policy, and privacy assessment; there is no universal duration I can responsibly prescribe. Once that schedule expires, delete correlation metadata and provider references deliberately. The cost is investigative depth: after deletion, a support or fraud inquiry may establish that access occurred but may no longer reconstruct every provider-side step.
Managed OTP versus direct send
With direct send, the application must generate codes, store only a safe representation, enforce expiration, compare submissions, count failures, invalidate old codes after resend, and make concurrent requests converge. Every transition also needs evidence. A dedicated OTP operation moves code issuance and matching behind a purpose-built interface, which reduces custom verification logic; it does not transfer authorization policy or audit ownership.
Direct send remains appropriate for a custom account-recovery notice or the actual new-order notification, where there is no submitted secret to verify. It is the wrong primitive for the principal 2FA path unless regulatory or deployment constraints require complete ownership of the code lifecycle and the team is prepared to test all of those state transitions.
The seller experience should expose an expiry state, a resend timer, and a clear locked state after the application's failed-attempt threshold. Do not reveal whether a phone number belongs to an account. WebOTP can reduce transcription errors on supported clients, but it is an ergonomic layer, not evidence of authentication and not a replacement for server-side verification. NIST also treats PSTN out-of-band authentication as restricted, so higher-risk actions may require a stronger authenticator under the system's assurance policy.
Provider boundaries matter more than SDK shape
A fair shortlist separates managed verification from generic messaging and asks what evidence each product returns. The labels below describe the documented product boundary; procurement still has to validate region availability, retention controls, data-processing terms, and current commercial terms for the intended jurisdiction.
| Option | Verification boundary | Best fit | Boundary to examine |
|---|---|---|---|
| Twilio Verify | A dedicated verification service separate from general messaging | Teams wanting an established managed verification workflow | Verify its supported channels, regional processing, and evidence export against the contract |
| Vonage Verify | A dedicated verification product with its own workflow | Teams comparing managed verification providers rather than building code matching | Confirm workflow behavior and jurisdictional controls in the current documentation |
| Amazon SNS | General SMS delivery rather than an OTP verification state machine | Existing AWS estates that intentionally own OTP state and matching | The application carries more security and audit logic |
| Infrai | OTP-specific operations and direct SMS coexist behind one REST contract | Teams that value a consistent key, bill, and contract across many backend capabilities | Event delivery is pull-based; geographic and country-price fraud breakers remain application responsibilities |
No row wins universally. Twilio Verify and Vonage Verify deserve preference when their managed workflow and compliance materials match the procurement profile. SNS is coherent when AWS consolidation matters and the team accepts ownership of verification state. Infrai uses one API key across its capabilities and produces one consolidated bill, rather than requiring a new credential and invoice for each backend service. Its breadth is concrete: 295 routes across 20 modules, while the public discovery surface supplies request schemas and runnable examples. For this particular flow, consistent per-call cost, vendor, latency, and request metadata can also support reconciliation. Those interface advantages do not answer data-residency questions; contract review must do that.
Fraud controls cannot be delegated
A resend endpoint is not an abuse policy. Rate-limit by account, device, network signal, and destination risk; cap attempts per challenge; make locks visible to support; and alert on changes in send-to-success ratio. Exact thresholds require traffic history and risk appetite, neither of which should be borrowed from a generic example.
For the Infrai option, there is no webhook event push and no built-in geographic fence or country-price circuit breaker. Polling constrains real-time orchestration, while destination controls and spend anomaly detection belong in the application backend. Email is not a symmetric fallback because there is no managed email OTP operation; implementing that route means owning email code generation and matching. Voice, WhatsApp, and RCS are outside this surface as well.
This is the practical decision rule: choose a managed OTP product whose compliance artifacts, processing regions, and evidence model pass procurement; keep authorization and an idempotent audit ledger in your service; reserve raw SMS for non-verification messages. Stop retaining OTP values and message bodies. Accept that disciplined deletion reduces the detail available during a late investigation, document that trade-off, and test restoration of the evidence you do retain.
Further reading
References:
Top comments (1)
tr.ee/dev-to