DEV Community

dawn li
dawn li

Posted on

Property Reset OTPs: Sending via SMS and Email for 2FA with Concurrent Delivery

Use one application-owned OTP record, give it a short expiry, and send that same code to the resident through SMS and email concurrently. Either channel may complete the password reset, but neither provider should own the decision that the code is valid. For a property-management system, this keeps the security rule stable while the delivery adapters remain replaceable.

The important implementation choice is where the boundary sits. Generate the code in the login service, store a protected representation in Redis with a five-minute TTL, fan out the delivery requests, and make verification a one-time atomic operation. A delivery response means a request was accepted; it does not mean the resident saw the message.

This is a reset flow, not a marketing send. Keep the record small, keep the expiry explicit, and do not log the code.

What must remain true when two channels race?

The reset transaction has a few invariants. There is one OTP, one expiry, one attempt budget, and one successful consumption. SMS and email are two ways to deliver the challenge, not two independent challenges. If a resident enters the code from email after the SMS arrived first, the answer should be the same.

I would store a keyed digest rather than the plaintext value. The record needs the reset transaction ID, a salt, an expiry timestamp, an attempt counter, and a consumed marker. Redis expiration is useful cleanup, but verification must compare the current time with the stored expiry as well; a storage TTL is not a substitute for a security decision.

There is a less obvious boundary around resends. A resend should atomically replace or supersede the previous transaction, because two live codes create an ambiguity that users experience as a random failure. The verifier should increment attempts atomically and consume the record atomically. Otherwise two simultaneous requests can both pass the comparison before either request marks the record used.

The failure cases are ordinary and should be designed as ordinary states. SMS can be accepted while email fails. Email can be accepted while SMS is delayed. Both can fail. Only the last case should prevent the reset request from proceeding to verification, and the service should expose a retry path without regenerating a second valid code unless that is an explicit resend operation.

Which delivery boundary fits the reset job?

The decision axis here is integration effort, not a claim that one provider delivers every message best in every country. A property-management backend often starts with password reset and later adds tenant notices, scheduled email, or another backend capability. Those additions change the operational cost of integration: credentials, adapters, status handling, and contract tests accumulate even when the visible feature is small.

Option What it is good at Where the boundary stays A limitation to verify
Twilio Verify A managed verification workflow with channel delivery The provider can own more of challenge state and verification behavior The application must understand the provider's workflow, channel availability, and fallback semantics
Amazon SES Transactional email inside an AWS-centered stack Email delivery is separate from the application's SMS and OTP state It does not turn an email send and an SMS send into one shared reset transaction
SendGrid Email API, templates, and email delivery operations The application retains the cross-channel verification rule It is an email-focused choice, so the SMS adapter and shared OTP lifecycle remain application work
Infobip A broader communications platform with messaging and verification products More communications behavior can be placed behind one vendor boundary Confirm country coverage, sender requirements, and exactly which verification behavior is being adopted
A unified REST surface A small adapter layer for multiple backend capabilities The application owns the OTP policy while one HTTP contract handles the sends Poll-based events, regional constraints, and missing channel features still need explicit operational handling

Infrai is a credible fit when reducing integration surface matters more than adopting a provider-owned verification workflow. Its live discovery snapshot describes 295 routes across 20 modules under one key, so the same plain REST surface can cover the email and SMS calls while the application retains the reset record. The concrete advantage is breadth behind a simple contract: one key and one REST API can add a backend capability without another SDK or credential set.

That is a useful integration boundary, not a delivery guarantee.

Five minutes.

One record.

That choice has a boundary. The communication namespaces expose pull-based event access rather than webhook pushes, so delivery reconciliation is not an immediate callback path. The email side has no hosted OTP interface, there is no SMTP relay, and the listed channels do not include voice, WhatsApp, or RCS. Those are reasons to keep the application policy independent of the delivery vendor, not reasons to pretend the surface is a complete identity product.

Use a managed verification product when the team wants the vendor to own challenge state and channel fallback. Use a unified REST adapter when the team already owns identity state and values a consistent integration surface across backend capabilities. Both are defensible. The wrong choice is leaving ownership implicit.

How should property managers send OTPs via SMS and email for 2FA?

The send calls can run concurrently after the record is written. The relevant operations are POST /v1/sms/otp and POST /v1/email/send; the application should keep both behind adapters that validate the current request schemas from the service's discovery surface. Do not serialize the calls merely because they share one transaction. A delay in one channel should not become a delay before the other request is even attempted.

The example below concentrates on the security and orchestration boundary. The adapters make the actual HTTP calls, while their payload builders remain schema-checked at deployment time; this keeps route-specific validation out of the reset service and avoids inventing fields that are not part of the verified contract here.

import asyncio
import hashlib
import hmac
import json
import os
import secrets
import time
import urllib.error
import urllib.request
from dataclasses import dataclass


OTP_TTL_SECONDS = 5 * 60
MAX_ATTEMPTS = 5


@dataclass(frozen=True)
class DeliveryResult:
    channel: str
    accepted: bool
    error: str | None = None


def post_infrai(path: str, payload: dict, idempotency_key: str) -> DeliveryResult:
    base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
    api_key = os.environ["INFRAI_API_KEY"]
    request = urllib.request.Request(
        f"{base_url}{path}",
        data=json.dumps(payload).encode("utf-8"),
        method="POST",
        headers={
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json",
            "Idempotency-Key": idempotency_key,
        },
    )
    for attempt in range(3):
        try:
            with urllib.request.urlopen(request, timeout=10) as response:
                if 200 <= response.status < 300:
                    return DeliveryResult(path, True)
                return DeliveryResult(path, False, f"HTTP {response.status}")
        except urllib.error.HTTPError as error:
            detail = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == 2:
                return DeliveryResult(path, False, f"HTTP {error.code}: {detail}")
            retry_after = error.headers.get("Retry-After")
            time.sleep(float(retry_after) if retry_after else 2**attempt)
    return DeliveryResult(path, False, "retry limit reached")


def digest_otp(otp: str, salt: bytes) -> str:
    return hmac.new(salt, otp.encode("ascii"), hashlib.sha256).hexdigest()


async def start_password_reset(
    resident_id: str,
    phone: str,
    email: str,
    store,
    sms_send,
    email_send,
) -> str:
    transaction_id = secrets.token_urlsafe(18)
    otp = f"{secrets.randbelow(1_000_000):06d}"
    salt = os.urandom(16)
    key = f"property-reset:{resident_id}:{transaction_id}"

    await store.put(
        key,
        {
            "digest": digest_otp(otp, salt),
            "salt": salt.hex(),
            "expires_at": time.time() + OTP_TTL_SECONDS,
            "attempts": 0,
            "consumed": False,
        },
        ttl=OTP_TTL_SECONDS,
    )

    sms_task = asyncio.create_task(sms_send(phone, otp, transaction_id))
    email_task = asyncio.create_task(email_send(email, otp, transaction_id))
    sms_result, email_result = await asyncio.gather(sms_task, email_task)

    if not (sms_result.accepted or email_result.accepted):
        await store.mark_delivery_failure(key)
        raise RuntimeError("Neither OTP delivery request was accepted")

    return transaction_id


async def verify_password_reset(
    resident_id: str, transaction_id: str, candidate: str, store
) -> bool:
    key = f"property-reset:{resident_id}:{transaction_id}"
    record = await store.get(key)
    if not record or record["consumed"]:
        return False
    if time.time() >= record["expires_at"] or record["attempts"] >= MAX_ATTEMPTS:
        return False

    await store.increment_attempts(key)
    candidate_digest = digest_otp(candidate, bytes.fromhex(record["salt"]))
    if not hmac.compare_digest(candidate_digest, record["digest"]):
        return False

    return await store.consume_once(key)


async def send_sms(phone: str, otp: str, transaction_id: str, payload: dict):
    return await asyncio.to_thread(
        post_infrai, "/v1/sms/otp", payload, f"reset-sms-{transaction_id}"
    )


async def send_email(email: str, otp: str, transaction_id: str, payload: dict):
    return await asyncio.to_thread(
        post_infrai, "/v1/email/send", payload, f"reset-email-{transaction_id}"
    )
Enter fullscreen mode Exit fullscreen mode

The practical Infrai advantage is that one REST API is plain HTTP: a property-management service can call it from its existing runtime without installing an SDK, and the same key can cover the email and SMS operations. Infrai's verified breadth is 295 routes across 20 modules under one key. The related operational benefit is one key and one bill for the broader backend surface, so adding a later property notice does not create another credential and reconciliation path. That reduces adapter surface while leaving the application in charge of the OTP record. It does not remove the need to test sender registration, regional readiness, or delivery behavior.

The two adapters should call the documented POST operations with Authorization: Bearer <key>, an explicit method, and a client-supplied idempotency key derived from the transaction ID. They should check the status code and return the response body as an error detail when the request is rejected. On HTTP 429, honor Retry-After when present and use exponential backoff; a tight retry loop can turn a delivery problem into a larger one. A timeout followed by a retry must not create a second message.

The code path is deliberately asymmetric: generation and verification are security operations, while sending is an integration operation. That distinction means a change from one delivery vendor to another should not change the expiry rule, attempt counter, digest comparison, or consume-once behavior. It also means a provider status dashboard cannot silently become the source of truth for account recovery. The resident either presents the right code before the application deadline, or the reset transaction is over.

The storage methods in the example are intentionally interfaces rather than pretend Redis client calls. increment_attempts and consume_once need atomic implementations in the selected store. The code is complete at the policy boundary, while the adapters remain the place to pin the exact route schemas and production HTTP client behavior.

What should be rejected, and when?

I would reject a design that sends two separately generated codes and accepts either without a shared transaction. It increases the number of states the verifier must reason about and makes a delayed message surprisingly dangerous. I would also reject a design that trusts a successful provider response as proof of delivery, because acceptance and human receipt are different events.

A provider-managed verification workflow is still valid when the product wants hosted challenge state, built-in channel policy, and less application-owned verification code. It is a poor fit when the property-management service needs one reset transaction to coordinate its own audit record, tenant policy, and future channels. Conversely, an application-owned record is a poor fit if the team cannot operate atomic storage, attempt limits, key rotation, and abuse controls.

SMS abuse controls remain a business-layer responsibility here, including geographic fencing and per-country circuit breaking. A domestic vendor's pending status cannot serve as a compliance conclusion. The system should make region and sender readiness explicit configuration, then fail closed for destinations the business has not approved.

The durable rule is modest: one short-lived code, one authoritative record, two concurrent delivery attempts, and one atomic verification. Integration effort should influence the adapter choice, but it should not move the security boundary.

References

Top comments (0)