Short answer: for a B2B SaaS report sent as an attachment, start with email verification when a managed work mailbox is the normal identity, and retain SMS as an explicitly logged recovery path. The deciding artifact is a defensible evidence chain, not a lower message quote: an auditor should be able to connect the login request, factor challenge, verification, and attachment release without seeing the secret code.
The workflow is small enough to fit in a notebook and serious enough to need production controls. A user requests a quarterly PDF, the service checks a login factor, and a job sends the attachment. I treat each transition as an event that can be replayed in an eval harness. That keeps prompt-cost and report-generation work out of the authentication decision.
What does an evidence gap cost in a report release?
Write the record shape before selecting a delivery channel. For every challenge, retain an event ID, account ID, purpose, channel, a salted destination digest, issue and expiry times, verification result, attempt count, and retention class. Store a digest of the code, never the code itself. The attachment event gets its own ID and references the verified challenge; “message accepted” is not equivalent to “factor verified.”
Email and SMS provide different possession signals. A corporate address can correlate neatly with an account, yet shared inboxes, forwarding rules, and mailbox takeover weaken that signal. A phone number is useful for reachability, while recycling and SIM-swap risk make it a poor sole gate for an administrator exporting sensitive data. Record those assumptions in the policy, rather than hiding them in a provider callback.
A practical record might look like this:
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import secrets
@dataclass
class Challenge:
account_id: str
purpose: str
channel: str
destination_digest: str
code_digest: str
salt: str
expires_at: datetime
event_id: str
attempts: int = 0
consumed: bool = False
def digest(value: str, salt: str) -> str:
return hashlib.sha256(f"{salt}:{value}".encode()).hexdigest()
def issue(account_id: str, purpose: str, destination: str, channel: str):
code = f"{secrets.randbelow(1_000_000):06d}"
salt = secrets.token_hex(16)
challenge = Challenge(
account_id=account_id,
purpose=purpose,
channel=channel,
destination_digest=digest(destination, salt),
code_digest=digest(code, salt),
salt=salt,
expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
event_id=secrets.token_urlsafe(12),
)
return challenge, code
def verify(challenge: Challenge, supplied: str, now: datetime) -> bool:
if challenge.consumed or now >= challenge.expires_at or challenge.attempts >= 5:
return False
challenge.attempts += 1
matched = hmac.compare_digest(challenge.code_digest, digest(supplied, challenge.salt))
if matched:
challenge.consumed = True
return matched
The adapter that sends code is intentionally outside this model. It can call SMTP, an email API, an SMS gateway, or a self-hosted relay. The evidence schema stays stable when transport changes, which is the useful portability boundary.
Keep it atomic.
How should SMS and email 2FA handle US/EU delivery evidence?
Delivery is a measurement problem. For email, authenticate the sending domain and monitor reputation, bounces, and complaint signals; Google's sender guidance documents SPF, DKIM, and DMARC expectations. For SMS, normalize numbers, capture carrier status, and test message length. GSM-7 and UCS-2 encoding can split one apparent message into multiple segments, so a friendly non-ASCII character may alter both latency and billing.
Do not collapse all statuses into otp_sent. Keep issued, accepted, delivered, verified, expired, and cancelled distinct, with timestamps from one clock. A callback that says delivered is useful telemetry, but only a correct code presentation authorizes the report. Regional tests should use synthetic US and EU accounts and the same locales used by the application.
I've seen the tempting fallback button in a notebook prototype: it looks harmless, but it can produce two challenges for one report and leave support unable to explain which destination authorized the file. I once assumed the extra button would make recovery safer. Instead, a six-minute delayed email, a 20-second SMS, and a duplicate browser click created three possible timelines; the support record had one generic otp_sent row and no reason for the switch. The repair was a state transition requiring a reason for every fallback, plus an event ID copied into the attachment job. Those cases now form test fixtures rather than production surprises, and the same fixtures run in staging before a template or routing change.
Rollout delayed delivery as an eval fixture
The smallest useful matrix covers expiry, replay, duplicate requests, wrong locale, provider timeout, and a user changing a phone number mid-flow. Add a race where an old email arrives after a newer SMS challenge. Assert that only the challenge bound to purpose="report_attachment" can release the attachment, and that a successful check consumes it atomically.
Keep metrics boring and comparable: completion rate, median verification time, fallback rate, suspicious-attempt rate, and the ratio of sent to verified challenges by region and channel. Replaying these cases from a notebook into staging gives the team a repeatable checkpoint before changing templates or routing rules.
Your mileage may vary. A consumer mailbox and a regulated corporate domain have different spam and forwarding behavior, so a single global threshold can conceal risk. I am not sure a five-minute expiry is right for every tenant; the evidence to settle that question is observed completion time and the tenant's risk policy, not intuition.
Data retention and regional review for the attachment trail
| Criterion | Email code | SMS OTP | Evidence to collect |
|---|---|---|---|
| Identity context | Existing business address | Possession of a number | Account binding and change history |
| Delivery risk | Spam placement and domain reputation | Carrier filtering and segmentation | Accepted, delivered, bounced or failed status |
| Attack surface | Mailbox takeover and forwarding | SIM swap and recycled numbers | Recovery events and factor changes |
| Operations | Template and domain governance | Sender registration and number formatting | Rollback steps and regional test results |
| Cost model | Message volume plus attachment bandwidth | Per-message and per-segment volume | Retry rate and segment count |
The catch is that SMS is not suitable as the only factor for privileged exports or recovery after a suspected mailbox takeover. Use a phishing-resistant factor such as WebAuthn, or a documented human review, for those paths. Conversely, email is a poor default when users do not have reliable mailbox access; forcing it creates support work and encourages unsafe bypasses. Keep the policy configurable so security can tighten one tenant without a code redeploy.
Choose the channel that supplies the right possession signal for the risk, then prove every transition around it. For a report attachment, the final authorization should be a signed, short-lived session decision tied to the verified event, never a link hidden inside an untracked message.
Before launch, verify domain alignment, exercise Unicode segmentation deliberately, redact destinations in logs, and alert on bursts of challenges or repeated fallback. Give support an event ID they can inspect without revealing the code. After launch, sample attachment events weekly; quarantine any report with no matching verified challenge and route users who lose both channels to the documented recovery process. These checks belong in the same release checklist as the report renderer, because a perfectly generated PDF is still an unauthorized disclosure when its factor event is missing.
Top comments (0)