Short answer: keep the SMS and backup-email templates in application-owned source control, create one two-factor authentication challenge, poll a normalized delivery state, and allow the email code only after SMS reaches a terminal failure or a fixed deadline. For a property-management portal, that boundary keeps login evidence separate from the compliance notice the authenticated user eventually sends. The transport can change; the wording, challenge policy, and audit schema stay under the team's control.
Don't treat “accepted by the SMS adapter” as “delivered to the user.” Those are different states.
The flow is small enough to draw in a sentence: a property manager requests a login code, the application renders its own SMS template, the SMS adapter returns a message ID, a worker polls that ID, and a policy function either keeps waiting or sends the application-owned email variant. Successful code verification closes the challenge regardless of channel. Only then can the manager enter the workflow that sends a lease-compliance notice and records that notice's separate delivery evidence.
How should Node.js two-factor authentication poll SMS delivery before backup email?
Use an explicit state machine rather than a timer callback that blindly sends a second code. The useful transport states are pending, delivered, and failed; the application adds a deadline so a message that remains pending forever cannot block recovery. A poll result should make one of three decisions: wait, mark the SMS leg delivered, or activate backup email. This gives a Node.js service a clean contract even if the adapter behind that contract changes. The executable model below is Python because the same state transition is easier to test without framework machinery, but the boundary is deliberately language-neutral: send, status, and an application-owned receipt ID.
| Observed SMS state | Deadline reached | Application decision |
|---|---|---|
pending |
No | Wait and poll later |
delivered |
Either | Keep email inactive |
failed |
Either | Activate backup email once |
pending |
Yes | Activate backup email once |
There is a sharp policy choice here. Falling back immediately after an adapter accepts a message creates two live delivery paths and can confuse a user who receives the SMS late. Waiting without a deadline can lock out a user when the carrier never produces a final receipt. Pick a deadline from observed delivery data, record it with the challenge, and test the edge exactly at that timestamp. I'm not sure a universal timeout exists; geography, carrier behavior, and the risk of the protected action all affect it, so your mileage may vary.
Keep the login event distinct from the property notice event. A record that says email_delivered proves only that the backup authentication message reached the state your adapter calls delivered. It does not prove that a compliance notice was delivered, opened, or legally served. Combining those timelines makes an audit export look complete while answering the wrong question.
Keep those ledgers apart.
Run the channel-neutral fallback example
This example uses only the Python standard library. It models a delivery adapter in memory so the transitions are deterministic in a notebook, a unit test, or a CI job. Replace the adapter methods at the production boundary; don't move template rendering or fallback policy into that adapter.
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
from secrets import randbelow
from typing import Protocol
from uuid import uuid4
class DeliveryState(str, Enum):
PENDING = "pending"
DELIVERED = "delivered"
FAILED = "failed"
class Channel(str, Enum):
SMS = "sms"
EMAIL = "email"
@dataclass(frozen=True)
class Message:
channel: Channel
destination: str
subject: str | None
body: str
@dataclass
class Challenge:
challenge_id: str
code: str
created_at: datetime
sms_deadline: datetime
sms_receipt: str
email_receipt: str | None = None
events: list[dict[str, str]] = field(default_factory=list)
class DeliveryAdapter(Protocol):
def send(self, message: Message) -> str: ...
def status(self, receipt_id: str) -> DeliveryState: ...
class InMemoryAdapter:
def __init__(self) -> None:
self.states: dict[str, DeliveryState] = {}
def send(self, message: Message) -> str:
receipt_id = f"msg_{uuid4().hex[:12]}"
self.states[receipt_id] = DeliveryState.PENDING
return receipt_id
def status(self, receipt_id: str) -> DeliveryState:
return self.states[receipt_id]
def render_code(channel: Channel, code: str) -> Message:
if channel is Channel.SMS:
return Message(
channel=channel,
destination="+15550102030",
subject=None,
body=f"Property Portal sign-in code: {code}",
)
return Message(
channel=channel,
destination="manager@example.com",
subject="Your Property Portal sign-in code",
body=f"Use {code} to finish signing in to Property Portal.",
)
def start_challenge(
adapter: DeliveryAdapter,
now: datetime,
sms_wait: timedelta,
) -> Challenge:
code = f"{randbelow(1_000_000):06d}"
sms_receipt = adapter.send(render_code(Channel.SMS, code))
return Challenge(
challenge_id=f"otp_{uuid4().hex}",
code=code,
created_at=now,
sms_deadline=now + sms_wait,
sms_receipt=sms_receipt,
events=[{"event": "sms_sent", "receipt": sms_receipt}],
)
def poll_and_maybe_fallback(
challenge: Challenge,
adapter: DeliveryAdapter,
now: datetime,
) -> str:
sms_state = adapter.status(challenge.sms_receipt)
if sms_state is DeliveryState.DELIVERED:
challenge.events.append(
{"event": "sms_delivered", "receipt": challenge.sms_receipt}
)
return "sms_delivered"
should_fallback = (
sms_state is DeliveryState.FAILED or now >= challenge.sms_deadline
)
if not should_fallback:
return "wait"
if challenge.email_receipt is None:
challenge.email_receipt = adapter.send(
render_code(Channel.EMAIL, challenge.code)
)
challenge.events.append(
{"event": "email_sent", "receipt": challenge.email_receipt}
)
return "email_active"
if __name__ == "__main__":
clock = datetime(2026, 1, 1, tzinfo=timezone.utc)
adapter = InMemoryAdapter()
challenge = start_challenge(adapter, clock, timedelta(seconds=45))
assert poll_and_maybe_fallback(challenge, adapter, clock) == "wait"
adapter.states[challenge.sms_receipt] = DeliveryState.FAILED
assert poll_and_maybe_fallback(challenge, adapter, clock) == "email_active"
assert poll_and_maybe_fallback(challenge, adapter, clock) == "email_active"
assert challenge.email_receipt is not None
assert len([e for e in challenge.events if e["event"] == "email_sent"]) == 1
The last assertion matters. Polling workers retry, processes restart, and two workers can observe the same state; the production operation that creates the email leg must therefore be atomic and idempotent. The in-memory code demonstrates the policy but not concurrency control. In a real datastore, make “email receipt is absent” part of a conditional update or transaction, attach an idempotency key derived from the challenge ID and channel, and have only the winner call the adapter.
Consider one concrete property-portal login. At 09:00:00Z, challenge otp_7f2 sends its application-rendered text and stores 09:00:45Z as the SMS deadline. Polls at 09:00:10Z, 09:00:20Z, and 09:00:40Z all observe pending, so they write observations but do not send email. A worker polling at exactly 09:00:45Z wins the conditional update, creates the email leg, and records the email receipt; another worker arriving a fraction later sees that receipt and performs no send. If the SMS adapter reports delivered at 09:00:47Z, the system retains that late observation, but it doesn't create a new challenge or a second email. When either copy supplies the valid code, verification records one consumed timestamp for otp_7f2. The later-arriving copy is harmless because the challenge is already closed. This timeline is the case I would pin in the evaluation suite: it exercises the deadline comparison, concurrent polling, late delivery, and single-use rule without pretending an adapter receipt proves that the compliance notice itself reached a tenant.
The code keeps one OTP for both channels. That reduces the number of active secrets and lets the verifier close one challenge after the first valid submission. The catch is that both copies may eventually arrive, so the verifier must reject the code after success or expiry. A team that requires channel-specific codes can issue a second secret at fallback, but then it needs an explicit rule that invalidates the SMS secret and explains the switch without leaking account state.
Keep template ownership at the message boundary
Template ownership is the architectural decision that survives provider changes. The application should render the semantic message — product label, purpose, code, locale, and expiry wording — and pass a finished payload to a narrow transport adapter. That keeps copy review, localization, snapshot tests, and compliance approval in the same release process as the authentication policy. Provider-hosted templates can be appropriate, but they move part of the release boundary into an external control plane and require a synchronization rule between application versions and template versions.
This is where notebook-to-prod discipline helps. Start with a table of cases, not a polished integration: SMS pending before deadline, SMS delivered, terminal SMS failure, deadline elapsed, duplicate poll, correct code, expired code, and already-consumed code. Turn each row into an evaluation with a fixed clock and adapter state. The prompt-cost instinct applies even though this flow has no model call: minimize hidden state, make inputs explicit, and pay for complexity only where the evaluation exposes a real failure mode.
An email fallback also needs ordinary mail authentication work. DKIM defines a domain-level signature that lets a verifier associate a message with a signing domain and validate signed content. It doesn't establish that a human read the message, and it doesn't turn a transport receipt into proof of notice. Treat DKIM configuration as part of the email delivery layer, while keeping the login challenge and property-compliance record as separate application objects.
Provider-owned templates are still a reasonable choice when non-engineering operators must change transactional copy immediately, or when a transport requires pre-registered content. Stick with them in those cases — but store the external template identifier and version in each audit event, review changes through an equivalent approval path, and test that every locale accepts the same semantic inputs. Application-owned templates are not suitable when the team cannot operate localization, sensitive-log redaction, and controlled releases itself.
Make the audit record explain each decision
An audit trail should answer what the system knew when it acted. Record the challenge ID, account pseudonym, channel, template version, adapter receipt ID, normalized state, observed timestamp, deadline, and decision. Do not store the OTP itself in the event stream. The compliance-notice workflow should have its own notice ID, document hash, recipient, template version, and delivery timeline, linked to the authenticated actor rather than folded into the OTP challenge.
A normalized state is intentionally less detailed than a provider response. Keep the raw response in a restricted operational store only if policy requires it, and map it to the application state at ingestion. For example, an adapter-specific status can map to pending without teaching the rest of the login service that vocabulary. This costs some diagnostic detail at the domain layer, but it prevents a carrier or email transport taxonomy from becoming authentication policy.
Be exact about evidence. “Sent” means the application handed off a message and received a receipt identifier. “Delivered” means the adapter reported the normalized delivered state. “Verified” means the user supplied a valid, live code. None of those statements means a property owner opened a compliance notice.
Short labels prevent long disputes.
Observability should follow the same boundaries. Count challenges by terminal decision, measure time spent pending, track the share that activates email, and alert on unusual shifts without putting destinations or codes into metric labels. Evaluate by locale and destination class where privacy policy permits; an overall success rate can hide a broken template variant. Costs belong in that harness too: count sends and polls per completed challenge, then compare policy changes on the same recorded cases rather than choosing a fallback deadline from intuition.
Operate the workflow without turning it into a vendor feature
Before deployment, run the transition suite with a fixed clock and force every branch, including the exact deadline and repeated polls. Review rendered SMS and email fixtures as release artifacts. Confirm that logs redact destinations and never contain codes, that receipt IDs can be traced across the adapter boundary, and that a consumed or expired challenge rejects later submissions. Then rehearse a transport swap against the same contract; template snapshots and policy evaluations should remain unchanged.
Roll out with a small cohort and watch decision distributions, not a vanity delivery percentage. A sudden increase in deadline fallbacks may indicate that the chosen wait no longer matches observed behavior, while a rise in duplicate email attempts points to missing atomicity. Neither signal tells you that a compliance notice failed, because that is a separate workflow with separate evidence. Keep it that way.
The design has limits. SMS and email are both possession-oriented channels, and a backup path can weaken the effective login policy if access to that path is easier to recover. For higher-risk actions, use the authentication method and assurance level required by your security program instead of assuming this fallback is sufficient. Template ownership won't settle that decision; it makes the selected policy reviewable, testable, and portable.
Top comments (0)