DEV Community

FlorianBlake3536
FlorianBlake3536

Posted on

Node.js Mobile SMS OTP Evidence for React Native: Administrator Recovery Controls

Short answer: for SaaS administrator recovery, treat a React Native SMS OTP as a short-lived, auditable transaction, not as a login screen feature; the backend should own the state machine, retain compliance evidence, and make every resend and verification attempt accountable.

The hard part is proving what happened after an administrator says, “That wasn't me.” A six-digit code is only one event in that story. You need a record of the recovery request, the destination that was selected, the message provider response, throttling decisions, verification result, and the identity that was restored. I design storage around that evidence trail first, then choose the transport.

What should a React Native SMS OTP backend record for administrator recovery?

Keep the mobile client deliberately thin. It asks for a recovery transaction, displays a masked destination, and submits the code. Node.js (or any equivalent backend) creates a random value with a cryptographic generator, stores only a salted digest, and gives the transaction an opaque identifier. The raw code should never be present in application logs, analytics events, or a support ticket.

An event row can be boring and still useful: transaction_id, account ID, actor ID if known, purpose (admin_recovery), destination fingerprint, creation and expiry timestamps, attempt count, resend count, request id, and final disposition. Store provider message IDs separately so an auditor can correlate your record with delivery evidence without granting the provider access to your account database.

Here is the shape of a verification boundary. It is intentionally a generic interface, so the same contract works with a hosted SMS gateway or an in-house adapter.

from datetime import datetime, timezone
import hashlib
import hmac
import secrets

def issue_code(transaction_id: str, secret: bytes) -> tuple[str, str]:
    code = f"{secrets.randbelow(1_000_000):06d}"
    digest = hmac.new(secret, f"{transaction_id}:{code}".encode(), hashlib.sha256).hexdigest()
    return code, digest

def verify_code(transaction_id: str, submitted: str, expected: str, secret: bytes) -> bool:
    candidate = hmac.new(secret, f"{transaction_id}:{submitted}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(candidate, expected)
Enter fullscreen mode Exit fullscreen mode

The code above does not decide whether a request is allowed. That decision belongs to a transaction state machine with explicit states such as created, sent, verified, expired, and locked. A database constraint should make verified terminal, and an idempotency key should make a retried network request produce one transaction rather than two messages.

How can mobile recovery state machines contain autofill abuse?

Autofill is a convenience, not proof of possession. On iOS and Android, use the platform's one-time-code hints and keep the code in the message format expected by the operating system; still require the backend to validate the transaction ID, expiry, purpose, and attempt budget. Never accept a code without binding it to the recovery transaction.

Resend needs two clocks. One limits how often a single transaction can generate a new code; the other limits requests by account, destination fingerprint, device signal, and network range over a rolling window. A new code invalidates the previous digest. Return the same generic response for an unknown account and a throttled account, otherwise the endpoint becomes an account-enumeration oracle.

I once changed a resend handler to return a helpful “phone not found” message. That tiny UX improvement produced a measurable stream of probes in our logs, including repeated 429 responses from the edge. We reverted to a generic acknowledgement and moved the detail into an internal audit event. The lesson was uncomfortable: support-friendly text can be a security boundary. The longer incident review found three separate counters had drifted: the mobile client retried after a timeout, the API gateway counted by IP, and the account service counted by user ID; each layer believed it was enforcing a limit, yet their windows reset at different moments, so a caller could walk through the gaps. We collapsed the policy into one versioned decision and emitted its rule ID with every denial.

Keep it boring.

A practical policy table looks like this:

Decision Evidence to retain User-visible behavior
New request request ID, purpose, destination fingerprint Generic acknowledgement
Resend allowed counter, policy version, timestamp Send a fresh code
Resend throttled rule ID and window Same acknowledgement, no message
Wrong code attempt number, transaction ID Retry until lock or expiry
Verified verifier, timestamp, resulting session ID Continue recovery

Which delivery and storage boundaries preserve compliance evidence?

Separate the communication adapter from the recovery ledger. The adapter can expose send_sms(destination, body, request_id) and return a provider message ID; it should not mutate account privileges. The ledger records the request before dispatch, then records dispatch outcome in a second append-only event. If dispatch times out, a worker retries by request ID and reconciles the provider status later. That is safer than guessing whether a message was sent.

Retention is a policy decision, not a default database setting. Keep enough metadata to demonstrate authorization and timing, but avoid retaining phone numbers or message bodies when a keyed fingerprint and provider reference answer the audit question. Encrypt sensitive fields, restrict who can query them, and make deletion jobs produce their own audit records. Your mileage may vary here: legal retention periods differ by jurisdiction, so the compliance owner must resolve that uncertainty before launch.

Do not put the OTP itself in an email, SMS delivery log, or crash report. Google’s sender guidance also makes clear that authentication mail and messages need authenticated, well-formed sending domains when email is part of the fallback path; an SMS-only flow still benefits from the same discipline around sender identity and traceability.

What are the trade-offs of SMS OTP for a SaaS administrator?

SMS reaches users who cannot install an authenticator, but it depends on a carrier-controlled channel and a mutable phone number. It is not suitable as the sole recovery factor for a high-risk tenant that requires phishing-resistant authentication. Stick with a passkey or hardware-backed factor when policy demands resistance to SIM-swap and real-time interception; keep SMS as a narrowly scoped break-glass path with extra review.

The operational cost is also broader than message fees: support must handle number changes, delivery delays, and locked transactions. A gateway abstraction reduces code churn, while a direct carrier integration can provide different delivery evidence and regional coverage. Neither choice removes the need for your own ledger, rate limits, and reconciliation job.

Roll out the recovery path without losing the audit trail

Ship the state machine behind a feature flag for one tenant cohort. Test duplicate requests, delayed callbacks, clock skew, concurrent verification, and a database failover; assert that every terminal state has an actor, request ID, and timestamp. Exercise the React Native autofill path on both platforms, but make the manual entry path equally complete.

During rollout, alert on spikes in resend throttles, verification failures by destination fingerprint, and transactions that remain sent past expiry. Review a sample of audit records with the compliance owner, then freeze the schema version used in evidence exports. The final decision rule is simple: if you cannot reconstruct who requested recovery, which code transaction was verified, and what privilege changed, the flow is not ready for administrators.

References

Top comments (0)