Short answer: issue one short-lived challenge, store only a verifier and delivery state, then let the learner use either SMS or email without treating the two paths as equally reliable. For an edtech login, the important result is a correct decision about the recipient, the code, and the channel state. A message accepted by a provider is not proof that a learner received it.
This walkthrough uses a small Python core that you can place behind a Node.js handler or another web boundary. The code is deliberately transport-neutral: the application owns the security policy, while adapters own provider-specific requests. That division makes delivery reliability testable before a real message is sent.
How should 2FA send OTPs via SMS or email?
Do not generate a separate secret for SMS and email. Generate one challenge, bind it to the account and an authentication attempt, and give the learner a controlled way to try the other channel. The server should accept the code once, before its expiry, and only after it has established that the challenge belongs to that attempt.
The data flow is short: normalize the account identifier, create a random code, save a salted verifier with an expiry and attempt counter, submit messages through two independent adapters, record each result, and verify against the stored verifier. A queued message, a provider acknowledgement, and a successful delivery event are three different states. Keeping them separate prevents a green API response from hiding a broken mailbox or a filtered phone number. My default policy starts with a 5-minute lifetime and 5 attempts, then changes only when abuse data or support evidence justifies it.
The policy is intentionally boring.
The example below is runnable as-is. Its transports print messages so the policy can be tested without network access. In production, replace those two adapters with implementations that speak your chosen email and SMS interfaces; keep the challenge service unchanged.
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import secrets
from typing import Dict, Optional
@dataclass
class Challenge:
account_id: str
verifier: str
expires_at: datetime
attempts_left: int
status: str = "pending"
class PrintTransport:
def __init__(self, channel: str):
self.channel = channel
def send(self, destination: str, message: str) -> str:
print(f"{self.channel} -> {destination}: {message}")
return "accepted"
class OtpService:
def __init__(self, secret: bytes, sms, email):
self.secret = secret
self.sms = sms
self.email = email
self.challenges: Dict[str, Challenge] = {}
def start(self, account_id: str, phone: Optional[str], address: Optional[str]) -> Challenge:
if not phone and not address:
raise ValueError("an enrolled delivery destination is required")
code = f"{secrets.randbelow(1_000_000):06d}"
verifier = hmac.new(
self.secret, f"{account_id}:{code}".encode(), hashlib.sha256
).hexdigest()
challenge = Challenge(
account_id=account_id,
verifier=verifier,
expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
attempts_left=5,
)
self.challenges[account_id] = challenge
message = f"Your learning account code is {code}. It expires in 5 minutes."
# Attempt both enrolled paths, but record each outcome independently.
if phone:
self.sms.send(phone, message)
if address:
self.email.send(address, message)
return challenge
def verify(self, account_id: str, code: str) -> bool:
challenge = self.challenges.get(account_id)
now = datetime.now(timezone.utc)
if not challenge or challenge.status != "pending":
return False
if now >= challenge.expires_at or challenge.attempts_left <= 0:
challenge.status = "expired"
return False
challenge.attempts_left -= 1
candidate = hmac.new(
self.secret, f"{account_id}:{code}".encode(), hashlib.sha256
).hexdigest()
if hmac.compare_digest(candidate, challenge.verifier):
challenge.status = "used"
return True
return False
service = OtpService(
secret=secrets.token_bytes(32),
sms=PrintTransport("sms"),
email=PrintTransport("email"),
)
service.start("student-1842", "+15551234567", "learner@example.edu")
print(service.verify("student-1842", "000000"))
The example demonstrates the security boundary, not a production storage layer. A real service should persist the challenge in a database or cache with an atomic update for attempts and consumption. The secret must come from a secret manager or environment configuration, not be regenerated for every process restart. It is also useful to store a challenge ID rather than using the account ID as the only key, so two concurrent login attempts cannot overwrite each other.
There is a deliberate trade-off here. Sending both messages immediately improves the chance that a learner can continue, but it increases message volume and can confuse someone who receives a late message. A fallback-after-timeout policy reduces duplicate messages but adds waiting time and makes the timeout a product decision. For a school login, I would decide from observed delivery events and support tickets, not from an assumed universal delay. I would rather expose a clear fallback state than silently tell a learner to wait for a message that already failed.
That distinction is small in code and large in support.
How do you model delivery without lying to the user?
Use a state machine per channel. queued means your application accepted the work. accepted means an external interface accepted a request. delivered means a delivery event was reported. failed means the provider or your adapter reported a failure. expired means the challenge is no longer usable. These labels should never be collapsed into a single sent boolean.
A useful record contains the challenge ID, account ID, channel, destination fingerprint, creation time, expiry time, provider message reference, current state, and the last error category. Avoid logging the code, full phone number, or full email address. A destination fingerprint can support diagnosis while reducing exposure in logs. Retain enough information to answer a support question such as “which path was attempted?” without creating a second copy of the secret.
Invalid recipients deserve their own policy. A hard bounce or a confirmed invalid phone number should suppress repeated sends to that destination until the learner updates it. A temporary failure can be retried with bounded backoff. An unknown result should not trigger an unbounded retry loop: duplicate codes are confusing, and a retry storm can become an availability problem during a school-wide login event.
The interface below is intentionally small. An adapter returns a normalized result; it does not decide whether an account is allowed to authenticate.
from dataclasses import dataclass
from typing import Literal
DeliveryState = Literal["accepted", "delivered", "failed", "unknown"]
@dataclass
class DeliveryResult:
state: DeliveryState
reference: str | None = None
error_class: str | None = None
def next_action(result: DeliveryResult) -> str:
if result.state == "delivered":
return "keep challenge open"
if result.state == "failed" and result.error_class == "invalid_recipient":
return "suppress destination and ask for an update"
if result.state == "failed":
return "retry once under the channel policy"
if result.state == "unknown":
return "wait for reconciliation; do not duplicate blindly"
return "await delivery event"
This is where an evaluation harness earns its keep. Before connecting an adapter, feed it fixtures for an invalid domain, a rejected phone number, a delayed event, a duplicate event, and a provider timeout. Assert that the user-visible state and retry decision match the policy. Then add a small end-to-end test with a fake transport. The test should prove that a wrong code never consumes a valid one, a valid code cannot be reused, and an expired code cannot authenticate.
Which standards and boundaries matter for 2FA delivery?
Treat SMS and email as delivery channels, not as interchangeable proof of identity. NIST SP 800-63B describes out-of-band authentication requirements and cautions that restricted authenticators need risk-aware treatment. The exact policy for an account depends on the threat model, enrollment process, recovery path, and institution requirements. A message code can be appropriate for a low-risk learning portal while being insufficient for a higher-risk administrative action.
Use a cryptographically secure random generator for the code, constant-time comparison for the verifier, a short lifetime, a bounded attempt count, and rate limits per account, destination, IP, and device signal. Rate limits should return a generic response so an attacker cannot use the endpoint to enumerate enrolled learners. Do not put the code in a URL, analytics event, exception, or chat transcript.
Templates also need an explicit contract. If a message is rendered from a template, escape user-controlled values and test missing variables. Mustache's specification documents its escaping behavior and the distinction between escaped and unescaped tags; the template engine is not a substitute for validating destinations or protecting the code.
One operational detail matters more than it first appears: clock handling. Store timestamps in UTC, compare against a server-side clock, and define what happens if an event arrives after expiry. A late delivery event may still be useful for deliverability metrics, but it must not extend the challenge lifetime.
What should the deployment checklist decide?
Start with the enrollment contract: require a verified destination, normalize it before sending, and make correction paths visible. Next, define the state transitions and persistence guarantees, especially the atomic “check and consume” operation. Then exercise the adapters with deterministic fixtures, including invalid recipients, timeouts, duplicate callbacks, and out-of-order events.\n+
For operations, watch challenge-start rate, verification success rate, expiry rate, invalid-recipient suppression, delivery latency by channel, and fallback frequency. Segment those measures by school, region, and destination domain only when the privacy policy permits it. Alert on a shift in failures rather than a single noisy event.
Finally, keep the message short, identify the learning service clearly, and tell the learner what to do if the code was not requested. The least complex architecture is usually one challenge service with two narrow adapters, durable delivery state, and an evaluation suite that makes failure behavior boring. That is the reliability decision: choose the design whose failure modes are visible and bounded, then improve channel selection from evidence.
Further reading
- NIST SP 800-63B, Authentication and Lifecycle Management: https://pages.nist.gov/800-63-3/sp800-63b.html
- Mustache template syntax manual: https://mustache.github.io/mustache.5.html
- RFC 6238, TOTP: Time-Based One-Time Password Algorithm: https://www.rfc-editor.org/rfc/rfc6238
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
Top comments (0)