A property-management contact form should not decide support ownership by guessing from a phone number, and an SMS OTP should not become a second, unrelated login flow. The practical trade-off is to keep identity verification in the authentication boundary while keeping queue selection in a versioned template owned by the support team. That separation makes it possible to implement SMS OTP-based 2FA in a Node.js API without letting authentication logic choose a business queue: the OTP proves control of a phone number for a short transaction, while the verified form payload selects maintenance, leasing, or resident-account support.
The implementation below uses the same backend shape I use for communication systems: a short-lived challenge record, a single-use verification transition, and an explicit routing policy. The example uses Python only to keep the protocol mechanics visible; the HTTP calls map directly to a Node.js client. No provider-specific SDK is required.
The five-minute expiry and five-attempt ceiling are policy inputs, not magic security constants.
How can I implement SMS OTP-based 2FA in a Node.js API?
An SMS OTP proves possession of a destination at one point in time. It does not prove that the sender is a tenant, that the unit number is valid, or that the message belongs in a particular queue. Those claims need separate checks. Treating the code as a general identity token is the first design mistake.
For a contact form, I would persist a challenge with a random identifier, a hash of the code, an expiry, an attempt counter, and the normalized destination. The plaintext code belongs in neither logs nor the form record. The form submission references the challenge ID and receives a server-side status such as verified; it does not send the code through the queueing path again.
The challenge lifecycle is deliberately boring:
- Normalize the phone number to an accepted international representation and apply a per-destination and per-IP rate limit.
- Generate a cryptographically random code and store only its hash, with a narrow expiry such as 5 minutes chosen by policy.
- Send the message through the communication API, but do not treat an accepted request as proof of delivery.
- Compare a submitted code against the stored hash, atomically consume the challenge, and record the result.
- Route the contact form using the verified state and a template revision.
The atomically consumed transition matters. A correct code accepted twice can produce duplicate tickets, duplicate callbacks, or a second privileged action.
Keep the state transition one-time.
Here is the core shape, with provider calls represented by an interface rather than a product SDK.
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import secrets
@dataclass
class Challenge:
id: str
destination: str
code_digest: str
expires_at: datetime
attempts: int = 0
consumed: bool = False
def create_challenge(destination: str, now: datetime) -> tuple[Challenge, str]:
code = f"{secrets.randbelow(1_000_000):06d}"
digest = hashlib.sha256(code.encode("ascii")).hexdigest()
challenge = Challenge(
id=secrets.token_urlsafe(18),
destination=destination,
code_digest=digest,
expires_at=now + timedelta(minutes=5),
)
return challenge, code
def verify_challenge(challenge: Challenge, submitted: str, now: datetime) -> bool:
if challenge.consumed or now >= challenge.expires_at or challenge.attempts >= 5:
return False
challenge.attempts += 1
digest = hashlib.sha256(submitted.encode("ascii")).hexdigest()
if not hmac.compare_digest(digest, challenge.code_digest):
return False
challenge.consumed = True
return True
In production, the read, attempt increment, expiry check, and consume operation need one transactional boundary. An in-memory object demonstrates the state machine; it is not a multi-instance store. The send operation should also carry an idempotency key derived from the challenge ID when the communication API supports one. That key protects retries at the application boundary, while the consumed flag protects the verification boundary. They solve different duplicate problems.
How does template ownership change the routing design?
The support queue should be selected from a small, reviewable template rather than from free-form model output or a pile of controller conditionals. A template owner can change the wording, required fields, and destination mapping without changing OTP security rules. The backend still validates the result against an allowlist.
For example, a property-management form might have these stable intents: maintenance, leasing, and resident_account. The template revision can require a unit reference for the first and third intents, while a prospective renter may have no unit yet. That is a business rule, not an SMS rule. Keep both in the record so a later audit can answer which policy made the decision.
ROUTES = {
"maintenance": "queue-maintenance",
"leasing": "queue-leasing",
"resident_account": "queue-resident-account",
}
def route_verified_form(form: dict, template_revision: str) -> dict:
if form.get("otp_status") != "verified":
raise ValueError("phone verification is required")
intent = form.get("intent")
queue = ROUTES.get(intent)
if queue is None:
raise ValueError("unsupported support intent")
if intent in {"maintenance", "resident_account"} and not form.get("unit_reference"):
raise ValueError("unit reference is required")
return {
"queue": queue,
"template_revision": template_revision,
"challenge_id": form["challenge_id"],
}
This is where ownership pays off operationally. The communication team owns message templates and their approvals; the support team owns queue mappings; the identity boundary owns challenge semantics. One team can revise a template while the other two retain their invariants. I would reject a change that silently combines those responsibilities, even if the first demo is faster.
That boundary is easy to lose.
Which failure modes deserve a test before launch?
Start with the edges that create confusing resident experiences or noisy queues. A valid code after expiry must fail. A wrong code must consume an attempt but reveal no information about the stored destination. Two simultaneous verification requests must not both win. A timeout from the SMS API must leave the challenge in a retryable state without claiming delivery. A form replay must not create a second ticket.
Rate limits need more than one key. Use destination, source address, account context where available, and a global send budget. Do not make the error response distinguish “number unknown” from “code wrong”; that distinction is useful to an attacker and rarely useful to a resident. Store structured events such as challenge.created, message.accepted, challenge.failed, and form.routed, with redacted destinations and correlation IDs.
I initially treated delivery status as the decisive signal. That was too narrow. An API can accept a message request while the handset remains unreachable, and a delivery receipt can arrive after the user has already abandoned the form. The useful product decision is therefore a bounded retry and an honest state shown to the caller, not a promise that the code will arrive. The long tail is where the implementation earns its keep: if the first request times out after the remote service accepted it, a blind retry can send two codes; if the callback arrives after the challenge expired, accepting it can reopen a transaction the user no longer controls. Correlation IDs, an idempotency key, and an explicit expiry check make those cases observable without pretending the network is synchronous.
The login path also needs a recovery policy. SMS is exposed to number reassignment, SIM-swap risk, filtering, and unavailable coverage. For higher-risk changes, require an additional factor or an out-of-band support process; do not stretch one SMS code into proof of every account attribute. NIST SP 800-63B is a useful reference for treating authentication factors and verifier controls as separate design concerns.
What should be compared in an API integration?
Compare boundaries, not logo checklists. A hosted messaging API can reduce carrier integration work, while a self-hosted gateway can provide more control over deployment and routing but shifts delivery operations to your team. A queue-oriented messaging service may make asynchronous status handling natural; a transactional email or notification service may expose a broader channel abstraction. None of those choices removes the need for local challenge storage, rate limiting, template ownership, or audit events.
The decision record should cover five concrete questions: can the integration authenticate requests without embedding long-lived secrets in application logs; can it expose request and delivery states distinctly; can the application retry without multiplying messages; can templates be versioned and approved; and can the team export enough events for incident review? Run a fake transport in tests and a contract test against the selected API in staging. Keep the provider adapter narrow so changing that boundary does not rewrite the verifier.
For Node.js, that usually means one module owns the API client and timeout policy, another owns challenge persistence, and the route handler composes them. Validate incoming JSON before calling either module. Use a monotonic request deadline, redact authorization headers, and make the final queue write idempotent. Those details are less exciting than the SMS call, but they are what keep a contact form from becoming an operational inbox full of duplicates.
A compact rollout for a property-management form
Ship the policy in stages. First, add the challenge table, metrics, redacted event schema, and a template revision field while keeping the existing form route observable. Next, enable verification for a small set of intents and test expired, replayed, throttled, and provider-timeout paths. Then require verified status for the queues that expose resident information, with a support-desk fallback that records why verification was unavailable.
Watch send attempts, accepted requests, verified completions, expiry rate, retry rate, and duplicate form suppression. Break those numbers down by destination region and intent; one aggregate success rate can hide a carrier or template problem. A rollback should disable the requirement or restore the previous routing revision without deleting challenge history.
The durable rule is simple: OTP is a narrowly scoped possession check, and the contact-form template is a separately owned routing policy. Keep those contracts explicit, versioned, and observable. The result is easier to secure and easier for a property-management team to change without asking an authentication change to carry business meaning.
Top comments (0)