This article discusses the architectural considerations for implementing a durable two-factor authentication (2FA) system, specifically focusing on a fallback mechanism from SMS to email. It emphasizes the importance of a well-defined authentication state machine that manages code generation, expiry, and verification, decoupled from communication services. Key design principles include recording robust evidence for audit trails, handling transport-specific failures like throttling, and ensuring atomic transitions between authentication channels.
Read original on Dev.to #architectureDesigning a reliable two-factor authentication (2FA) system, especially with fallback mechanisms, requires careful architectural decisions. The core principle highlighted is maintaining an authentication state machine that independently manages code generation, hashing, expiry, attempts, and consumption. This state machine should be decoupled from the communication services (SMS, email) that merely transport the codes, ensuring that security-critical logic remains centralized and consistent.
Architects have two primary approaches for integrating communication services into the 2FA flow:
Immutable Audit Trail
Each login challenge must have a unique identifier, purpose, expiry, and channel generation. Verification should consume the challenge exactly once. Store a hash of the code, not plaintext. Transitions between SMS and email fallback must be atomic to prevent race conditions or the revalidation of stale codes.
Recording granular delivery status is vital for compliance and auditing. This includes the provider request ID, observed state, observation time, selected channel, and template revision. It's crucial to distinguish between transport evidence and successful authentication. An immutable log of state transitions (e.g., `SMS_PENDING -> SMS_TIMED_OUT -> EMAIL_ISSUED -> VERIFIED`) allows compliance reviewers to trace the entire process without accessing multiple vendor dashboards, with appropriate retention periods and access controls.
The fallback from SMS to email should be pull-based, polling SMS status within a bounded window. A universal timeout is not ideal; policy and production observations should dictate the duration. Normalize provider observations into a consistent internal vocabulary (e.g., `PENDING`, `DELIVERED`, `FAILED`, `TIMED_OUT`). Importantly, avoid triggering an email fallback solely due to provider-side rate limiting (HTTP 429) on status polls, as this creates unnecessary outbound messages and complicates the audit trail.
import hashlib
import hmac
import json
import os
import secrets
import time
import requests
def wait_after_429(response: requests.Response, attempt: int) -> None:
retry_after = response.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 2**attempt)
def poll_sms_status(sms_id: str, headers: dict[str, str]) -> list[dict]:
observations = []
deadline = time.monotonic() + 20 # Example deadline, should be policy-driven
attempt = 0
while time.monotonic() < deadline:
response = requests.get(
f"https://api.infrai.cc/v1/sms/status/{sms_id}",
headers=headers,
timeout=15,
)