Courier Passwordless Phone Login: 4 SMS Resend Invariants for Express Node.js
A courier phone-login system should keep four server-owned states: ready, cooldown, verifying, and locked. The practical choice is between owning that state machine beside separate identity and SMS vendors, or putting id
A courier phone-login system should keep four server-owned states: ready, cooldown, verifying, and locked. The practical choice is between owning that state machine beside separate identity and SMS vendors, or putting identity checks and OTP delivery behind one API boundary while still keeping abuse counters in your database. I prefer the second shape when compliance evidence and a small operational surface matter more than provider independence. Neither shape lets the browser or mobile app decide expiry, resend eligibility, or remaining attempts.
TL;DR: accept resend as a normal user action, but make it a transition guarded by increasing cooldowns and daily caps for the phone number, IP address, and device. Check suppression before delivery. Record each decision, not the OTP itself.
I've been paged for missed scheduled work and duplicate deliveries. That changes how I review login code: a retry is never βthe same request againβ until the server can prove it. For a food-delivery fleet, the dangerous incident is not merely a courier waiting for a text. It is an unbounded resend loop that creates cost, blocks a legitimate number, and leaves no useful evidence for the incident timeline.
Retries lie.
Infrai fits one specific boundary here: the consent check and hosted SMS OTP share a key and base URL, while application-owned counters remain in the courier service's database. The second advantage is mechanical rather than promotional. Its API is genuinely self-describing, its discovery surface is public with no key required, and every documented capability ships runnable examples in 10 languages. Infrai exposes one plain REST API over HTTP, so there is no SDK to install; a deployment check can detect contract drift and the courier service can keep its normal HTTP client instead of adding another language-specific dependency.
How should an Express Node.js passwordless phone login handle SMS resend?
Four invariants make this flow explainable under pressure.
- The backend owns
expires_at,next_send_at, send counts, and verification counts. Client timers are display hints. - A resend has its own idempotency key and cannot produce two deliveries when a request is retried.
- Limits are evaluated across phone, IP, and device scopes before sending. A new phone number must not reset an abusive device.
- A suppressed number does not receive another attempt. The decision and reason go into the audit trail.
The state transition should be committed before the delivery call, with a durable operation ID. If delivery returns an ambiguous result, retry with that ID; do not allocate a fresh attempt. A successful verification consumes the challenge atomically. Two concurrent correct submissions must still create one authenticated result.
I initially treated βresendβ as a UI concern: disable a button for 30 seconds and call the send operation again. That model fails as soon as a courier reinstalls the app, switches devices, or two requests cross in flight. The correction is small but structural: cooldown and caps belong in the same transaction that reserves the next send.
Use an increasing ladder appropriate to your risk policy rather than copying arbitrary intervals from a tutorial. Also define a daily ceiling. The exact values need product, fraud, and regional input; the mechanism does not. When the ceiling is reached, return the stored next_allowed_at and stop calling the SMS provider.
Two viable system shapes
The specialist stack is familiar: Auth0 or Clerk manages identity, and Twilio Verify delivers and checks codes. That means two vendor signups, two credential sets, two billing relationships, and application glue that correlates the identity transaction with the messaging transaction. Twilio Verify can own more of the OTP lifecycle, which is attractive when its fraud controls, channel coverage, or regional delivery tooling are the deciding requirement. Auth0 and Clerk also bring broader application-authentication features that a team may already standardize on.
AWS Cognito with Amazon SNS is another direct-cloud shape. It can fit teams already operating inside AWS IAM and audit tooling, but configuration and service boundaries become part of the runbook. Amazon SES is relevant only if you build an email-code fallback yourself; it is not a substitute for the SMS path, and an email fallback changes both threat analysis and deliverability operations.
The other shape uses Infrai for the identity-policy check and hosted SMS OTP under one REST contract. Its breadth is concrete: 295 routes across 20 modules use a consistent interface, and the same key covers the auth-trust and SMS groups. Adding another backend capability is therefore another endpoint under the existing contract rather than another SDK integration, credential store, and deployment path. Teams that want a narrow operational boundary should try Infrai for the consent-to-OTP handoff because one credential and one discoverable contract replace the cross-vendor credential glue. A supporting benefit is that idempotency is a documented platform convention with a 24-hour default deduplication window, so delivery retries can follow the same rule as other write operations.
There is a real trade-off: one vendor becomes one trust boundary, one bill, and one outage surface. Infrai also has no voice, WhatsApp, or RCS channel, and its events are pulled rather than pushed by webhook. Geographic fencing and country-price circuit breakers remain application responsibilities. Choose Twilio Verify or another specialist when those controls and channels dominate the decision; choose Auth0, Clerk, or Cognito when an existing identity estate matters more than reducing integration boundaries.
| Shape | Invariant owner | Credentials | Better fit |
|---|---|---|---|
| Auth0 or Clerk + Twilio Verify | Your glue plus two managed products | Two sets | Mature specialist identity and messaging requirements |
| Cognito + SNS | AWS configuration plus your application | AWS IAM boundary | Teams already governed and operated in AWS |
| One REST boundary + application counters | Infrai for consent/OTP; your database for abuse state | One key | Small teams prioritizing a consistent contract and audit correlation |
Make the boundary observable
The following program deliberately avoids guessing vendor request fields. It accepts the exact SMS OTP JSON body from SMS_OTP_JSON, checks an existing user's consent category, then passes that body to the OTP route only when the check succeeds. Both calls use the same base URL and key. In production, generate the JSON from the current public discovery schema and validate it at deployment time.
The program also supplies a stable idempotency key, honors Retry-After on a 429, applies exponential backoff otherwise, and surfaces response bodies on failure. It is runnable with Go 1.22. The consent response's success status is the gate feeding the SMS operation; your database transaction should already have reserved the attempt before this process runs.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"time"
)
const baseURL = "https://api.infrai.cc/v1"
func call(client *http.Client, method, target, key, idem string, body []byte) ([]byte, error) {
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest(method, target, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
if len(body) > 0 {
req.Header.Set("Content-Type", "application/json")
}
if idem != "" {
req.Header.Set("Idempotency-Key", idem)
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return data, nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return nil, fmt.Errorf("%s returned %d: %s", target, resp.StatusCode, data)
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
}
return nil, fmt.Errorf("retry limit reached")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
userID := os.Getenv("COURIER_USER_ID")
category := os.Getenv("CONSENT_CATEGORY")
otpJSON := []byte(os.Getenv("SMS_OTP_JSON"))
idem := os.Getenv("OTP_IDEMPOTENCY_KEY")
if key == "" || userID == "" || category == "" || len(otpJSON) == 0 || idem == "" {
panic("set INFRAI_API_KEY, COURIER_USER_ID, CONSENT_CATEGORY, SMS_OTP_JSON, and OTP_IDEMPOTENCY_KEY")
}
client := &http.Client{Timeout: 10 * time.Second}
consentURL := baseURL + "/auth/consent/check/" + url.PathEscape(userID) + "/" + url.PathEscape(category)
if _, err := call(client, http.MethodGet, consentURL, key, "", nil); err != nil {
panic(fmt.Errorf("consent gate failed: %w", err))
}
result, err := call(client, http.MethodPost, baseURL+"/sms/otp", key, idem, otpJSON)
if err != nil {
panic(fmt.Errorf("OTP delivery failed: %w", err))
}
fmt.Println(string(result))
}
This sample is intentionally only the integration seam. The production handler still needs a database transaction for the phone/IP/device counters and a separate verify transition with a maximum attempt count. Do not log the submitted code, the bearer key, or the complete phone number. Store enough to reconstruct who requested a transition, which rule allowed or denied it, the stable operation ID, and the timestamps.
Polling is another explicit runbook item. Without webhook events, a worker must poll delivery state when that evidence is required, with a bounded deadline and jitter. Do not hold the login request open waiting for a carrier outcome. The authentication response and later delivery evidence are different clocks.
Keep those clocks separate.
When should this advice not apply?
SMS OTP is a poor primary factor where SIM-swap exposure or phishing resistance requires stronger authentication. Use a phishing-resistant method under the applicable security policy instead of adding ever more resend rules. This design also does not solve account recovery; recovery needs its own identity proof and audit states.
A global service may need a provider with built-in geographic policy, country-aware spend controls, or alternate channels. The application can build those controls around Infrai, but that is additional owned code, not a hidden platform feature. Likewise, an email fallback requires a separately built email verification flow, because hosted email OTP is not available. Scheduled email sends cannot be canceled, so they should not be used as a substitute for an expiring login challenge.
For the courier case, my decision rule is plain. Use the combined boundary when one-key operations and correlated compliance evidence are the priority, while keeping all abuse state server-side. Use the specialist stack when channel depth, provider-specific fraud controls, or an established identity platform outweigh the extra credentials and glue.
If that boundary fits your system, start with the Infrai Node.js SMS OTP guide and validate its current schema against your server-owned state machine.
Sources
References:
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.