Authorize a property-management receipt only when the backend can connect one successful SMS challenge to one server-created session. Treat resend as a state transition, not another send button. The practical rule is short: Express owns expiry, cooldown, attempt budgets, and lockout; the SMS service owns delivery and code verification.
Short answer: model send, resend, verify, expire, and lock as explicit transitions. Apply increasing cooldowns and daily caps across phone, IP, and device, check suppression before delivery, and retain only enough evidence to reconstruct the authorization decision. This design supports passwordless access without accepting a browser timer as a security control.
What must a receipt-access record prove?
Begin at the payment boundary. A settled payment creates the receipt, but it does not prove that the person requesting access controls the phone number associated with the property account. The audit record therefore needs to connect a challenge identifier, its policy version, a successful verification time, and the resulting session to the receipt action. An accepted SMS send is delivery evidence, not authentication evidence.
The database is authoritative for expires_at, next_send_at, attempts remaining, daily counts, and lockout. Each failed verification consumes an attempt atomically. A resend advances the cooldown without restoring attempts, and only one success may consume the active challenge. Concurrent tabs and devices must observe the same state.
This is the control.
Keep the failure boundary explicit. The browser can display a countdown, but clearing storage cannot reset policy. A provider timeout cannot create a second logical send. A delayed delivery event cannot reverse a completed verification. Geographic fences and country-level billing circuit breakers belong in the application because the SMS surface does not provide them.
Suppression is an early decision, before message creation. Sending repeatedly to a blocked number adds cost and creates noisy records without improving access. If the number is suppressed, record a bounded outcome and stop.
Compare who owns the challenge
The useful comparison is not a feature tally. It is the location of the verification state and the evidence needed by the property operator.
| Option | Service boundary | Application responsibility | Best fit |
|---|---|---|---|
| Twilio Verify | Managed verification workflow | Receipt authorization, account policy, and evidence retention | Teams wanting a specialist verification product |
| Vonage Verify | Managed verification workflow with its own provider footprint | Session issuance, business lockouts, and audit linkage | Teams evaluating a second specialist footprint |
| Amazon SNS | SMS transport | Code generation, verification, resend state, suppression, and lockout | AWS-centered systems that intentionally own the full challenge |
| Infrai | Hosted SMS OTP and related communications behind a broader REST surface | Abuse counters, lockout, geographic controls, and event polling | Teams that value one contract across multiple backend capabilities |
Infrai is credible in the last category because its public discovery surface describes 295 capabilities across 20 modules, and documented capabilities include runnable examples in 10 languages. One key and one REST contract can keep communications from becoming another SDK and credential boundary. Its separate advantage here is contract inspection: request and response schemas, billing information, and examples are exposed through discovery before integration.
That breadth does not absorb the auth policy. SMS events are pull-based rather than delivered by webhook, so reconciliation is not instantaneous. There is no hosted email OTP endpoint for a fallback challenge, and voice, WhatsApp, RCS, and SMTP relay are outside the available channel set. Twilio Verify or Vonage Verify is the more natural evaluation when specialist verification or a different channel footprint governs the decision. SNS is appropriate when the team wants transport inside AWS and accepts ownership of every challenge transition.
How should Express Node.js handle a passwordless phone login flow?
The client should call an Express endpoint, never the SMS provider directly. Express normalizes the number, evaluates phone/IP/device limits in one database transaction, checks suppression, allocates the challenge, and then makes the provider call. This keeps credentials and counters off the device. The anti-abuse policy should store both the active resend cooldown and the max-attempts budget; checking one without the other leaves either the send path or the guessing path unbounded. A rejected request records which bounded policy class stopped it, but it does not place the phone number or device identifier in a metrics label.
The transport call below is intentionally narrow. INFRAI_BASE_URL and INFRAI_API_KEY are deployment secrets; the idempotency key is derived from the server-side challenge and remains stable across retries.
curl --request POST \
--url "${INFRAI_BASE_URL}/sms/otp" \
--header "Authorization: Bearer ${INFRAI_API_KEY}" \
--header "Content-Type: application/json" \
--header "Idempotency-Key: receipt-challenge-018f2f2c" \
--fail-with-body \
--retry 4 \
--retry-all-errors \
--retry-delay 1 \
--data '{"to":"+14155550123"}'
The explicit method and --fail-with-body keep a 4xx response from looking successful. Curl bounds transient retries, retries HTTP 429, and honors Retry-After when the server supplies it. The stable idempotency key makes an ambiguous network retry refer to the same write. After success, Express stores the returned opaque identifier beside its own challenge record; it does not accept expiry or counters from the client.
Verification should also cross the application boundary:
curl --request POST \
--url "http://localhost:3000/auth/receipt-access/verify" \
--header "Content-Type: application/json" \
--data '{"challenge_id":"challenge-018f2f2c","code":"123456","device_id":"browser-7f3a"}'
On mismatch, Express atomically reduces the remaining budget and records a coarse failure outcome. On success, it consumes the challenge and creates the session allowed to fetch or send the receipt. A second concurrent success fails. Resend remains attached to the same logical challenge and cannot replenish guesses.
No client arithmetic.
Budget evidence by bytes and cardinality
Compliance evidence becomes an observability liability when every identifier turns into a label. Keep challenge_id in a short-lived audit store for direct lookup, not in a metrics dimension. Aggregate metrics can use bounded fields such as policy version, region, transition outcome, and retry class. Phone numbers, IP addresses, device identifiers, raw codes, and message bodies should not become metric labels.
Do the retention math before choosing a window. At 400,000 challenges per day, one 300-byte transition record retained for 90 days is about 10.8 GB before indexes and replicas: 400,000 x 90 x 300. That is an illustrative capacity calculation, not a measured workload. Index amplification, replication, and multiple transitions per challenge increase the stored total, so the actual budget must use serialized record sizes from the deployed schema.
Sampling has an asymmetric trade-off. Routine success traces can be sampled more aggressively when policy permits, while lockouts, suppression decisions, and exhausted retry budgets carry more diagnostic value. Audit transitions required to prove authorization should follow the retention rule independently of trace sampling. Logs answer operational questions; the ledger answers who was allowed to act.
Reject the stateless resend endpoint
A stateless endpoint paired with a browser countdown is the rejected option. Local storage can be cleared, concurrent tabs race, and another device has no shared daily cap. It may deliver SMS messages correctly, yet it cannot consistently explain why a resend or verification was permitted.
This simpler design still has a valid boundary: a low-risk notification sent after an already authenticated action. For example, a resident who is signed in can receive notice that a receipt is ready without treating the message as proof of phone possession. Suppression and sending limits remain necessary, but there is no OTP state machine to defend.
For passwordless receipt access, choose the provider after choosing ownership. Managed verification reduces the challenge surface the application implements; raw transport maximizes application control; a broad REST platform reduces integration variety. None removes the need for server-owned abuse counters, minimal evidence, and an explicit lockout transition.
References
- OWASP, Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- NIST, Digital Identity Guidelines, Authentication and Authenticator Management: https://pages.nist.gov/800-63-4/sp800-63b.html
- Twilio Verify documentation: https://www.twilio.com/docs/verify/api
- Twilio, SMS character limits and segmentation: https://www.twilio.com/docs/glossary/what-sms-character-limit
- Vonage Verify API documentation: https://developer.vonage.com/en/verify/overview
- Amazon SNS mobile text messaging documentation: https://docs.aws.amazon.com/sns/latest/dg/sns-mobile-phone-number-as-subscriber.html
- Amazon SES documentation: https://docs.aws.amazon.com/ses/latest/dg/Welcome.html
Top comments (0)