Short answer: for a property-management SaaS, make email OTP the measured default only when the resident or operator's mailbox is an independent, reachable factor; offer SMS OTP when phone reachability is stronger, and never let a delivery failure silently become an authentication success. The best practice for US/EU 2FA is a policy based on evidence: completion latency, security context, bounce history, suppression state, and an auditable reason for each channel decision.
That sounds less tidy than “SMS is faster” or “email is cheaper.” Good. Login systems fail at the edges, and a resident who cannot receive a code is also the person most likely to click resend five times. The application needs one challenge state, one expiry policy, and a record of why a destination was accepted or suppressed.
Start with the recipient record, not the channel
Property management has an awkward data shape. One unit may have several residents. An owner may use a work mailbox. A leasing operator may share a role address. Phone numbers can be recycled, mistyped, or attached to a former occupant. An email address can remain in a tenant record after a mailbox is closed. “The contact field is populated” is not evidence that the destination is valid.
For every destination, keep a small, explainable history: when it was last confirmed, which channel it belongs to, whether a delivery was accepted, whether the message hard-bounced, and whether the address is suppressed. A hard bounce or a provider-level invalid-recipient signal should stop automated sends to that destination. Do not turn a bounce into a new OTP attempt against the same address. That creates noise, weakens your evidence, and can make an already-invalid contact look active in an internal dashboard.
The suppression decision should be reversible through an explicit verification flow. A property manager correcting an address is not the same event as an SMTP retry succeeding later. Record the actor, timestamp, previous value, and evidence used to restore a destination. Keep the login challenge linked to the contact version it evaluated, so a newly edited phone number cannot inherit an old challenge accidentally.
One record. One decision.
This is where compliance evidence becomes a design constraint. A reviewer should be able to answer: which destination received the challenge, why was that destination eligible, what happened to the delivery, and which event allowed the login to finish? A dashboard that only shows “sent” cannot answer those questions.
How should US and EU SaaS teams compare SMS OTP and email OTP for 2FA login?
Compare the channels across four separate measurements: delivery, latency, security, and control. Do not collapse them into one score.
Email delivery is shaped by the sending domain, authentication alignment, mailbox reputation, filtering, forwarding, and the recipient's own provider. DMARC describes a policy and reporting framework for domain owners; it is part of a sender identity and anti-spoofing program, not proof that a particular OTP reached a person. Apple Mail Privacy Protection is another reason not to use an open as the success signal. An open is not verification. A correct code submission is.
SMS delivery has a different failure surface. The phone may be roaming, the number may have changed hands, or a user may be exposed to social engineering and SIM-related takeover. SMS can be quick and familiar, but a quick transport response is not the same as a completed authentication. For both channels, measure from challenge creation to successful verification, then break the result down by country, destination type, account cohort, resend count, and suppression status. A regional tail matters more than a global average when a building manager is waiting at a front desk.
I'm not sure one US/EU latency target can be defensible without the application's own slices. Your mileage will vary with mailbox domains, carriers, tenant behavior, and the time of day. Set an initial policy, collect completion and expiry data, and revisit it with security and support teams. Do not infer receipt from an email open or from an API request that was accepted for processing.
Security needs the same discipline. Email OTP is a poor choice when the same mailbox is also the password-reset route and the only recovery evidence. SMS OTP is a poor choice when the phone number is unverified, recently changed, or subject to a high-risk account event. A stronger factor should be required for changing either destination. Neither channel should be described as phishing-resistant.
What state machine handles bounces, retries, and late OTP delivery?
Model the challenge separately from the message. A challenge has a purpose, an account, a contact-version identifier, an allowed channel, an expiry, an attempt counter, and a terminal state. Useful terminal states include verified, expired, locked, superseded, and delivery-suppressed. Only a successful code check may complete the login.
The message record should retain transport evidence without granting it authentication authority. For email, a hard bounce moves the destination into suppression and makes the current channel ineligible. For SMS, an undeliverable or invalid-recipient result should have the same effect for that phone number. A transient delivery delay is different: it can justify a visible retry policy, but it must not create a second active challenge merely because the first message is late.
Here is the shape of the decision logic. The endpoint names are intentionally generic; the important contract is the state transition and its audit record.
from dataclasses import dataclass
from enum import Enum
class ChallengeState(Enum):
ACTIVE = "active"
VERIFIED = "verified"
EXPIRED = "expired"
LOCKED = "locked"
SUPERSEDED = "superseded"
SUPPRESSED = "suppressed"
@dataclass
class Challenge:
state: ChallengeState
attempts: int
contact_version: str
def accept_code(challenge: Challenge, code_is_valid: bool) -> ChallengeState:
if challenge.state is not ChallengeState.ACTIVE:
return challenge.state
if code_is_valid:
challenge.state = ChallengeState.VERIFIED
return challenge.state
challenge.attempts += 1
if challenge.attempts >= 5:
challenge.state = ChallengeState.LOCKED
return challenge.state
The number five here is an example policy value, not a universal standard. Put such values in configuration, record policy changes, and test them against abuse data. A resend should atomically supersede the prior challenge or follow an equally explicit rule. Two browser tabs must not be able to verify two different active codes for one login.
I have seen the practical version of this race: the first message arrives after the user has already chosen the other channel, and a stale screen still accepts it. Picture a leasing operator working from a browser with two tabs open. In the first tab, an email challenge is created for the operator's old contact version. The mailbox is slow, so the operator edits the profile, confirms a phone number in the second tab, and requests SMS. If the database stores only account_id and code, both tabs can now appear valid: the old email can be accepted after the new phone challenge has been created, the resend counter can be charged to the wrong destination, and a support investigator cannot tell whether the login used the current or former contact. A safer transaction reads the active challenge and contact version under the same consistency rule that writes the new challenge, marks the old challenge superseded, and records the reason as a channel transition. The verification query checks the challenge identifier, purpose, contact version, state, expiry, and attempt budget before it checks the code. A late transport event may still be useful for delivery analysis, but it cannot move a superseded challenge back to active. The stale tab gets a neutral replacement result and refreshes its display; it does not learn whether another account exists. This is a long chain of small decisions, but it is exactly the sort of chain that a compliance review and an incident investigation need to reconstruct. The fix is boring and effective. Read the current challenge before verification, reject superseded challenges, and return a neutral result that does not reveal account existence. For transport retries, honor Retry-After after HTTP 429 when available, add jitter when backing off, and make the application transaction idempotent.
Which evidence makes the decision reviewable?
Build the audit trail before adding a fallback button. At minimum, capture a pseudonymous account identifier, contact version, channel, policy decision, challenge identifier, timestamps, outcome, suppression reason, and operator action. Keep code values out of logs. Limit access to delivery metadata because recipient addresses and phone numbers are personal data in many operating environments.
For email, use authenticated sending and align the domain policy with DMARC. Separate authentication traffic from bulk campaigns where the operating model permits it, and maintain a suppression list that support staff can inspect without exposing message contents. For SMS, retain destination validation, consent and policy evidence required by the deployment, and a country-aware sending decision. The exact legal treatment varies by jurisdiction and message purpose, so counsel must turn that decision into enforceable product rules rather than leaving it as a wiki note.
The useful metrics are completion latency, expiry rate, resend rate, invalid-recipient rate, hard-bounce rate, suppression hits, fallback rate, and suspicious verification attempts. Slice them by US/EU destination and by property-management role. A resident, a landlord, and a support operator may have different contact quality and risk profiles. Alerting on a sudden rise in “sent” events is weak; alerting on a rise in invalid recipients or failed verification is actionable.
| Evidence | What it can establish | What it cannot establish |
|---|---|---|
| Successful code verification | Control of the active channel for this challenge | That the contact belongs to the intended person |
| Hard bounce or invalid-recipient result | A reason to suppress the destination | That another channel is safe without its own checks |
| Delivery latency | How long this cohort took to complete or expire | A universal US/EU service-level target |
| Mail open event | At most, a noisy engagement signal | Receipt, identity, or authorization |
Do not let fallback hide bad data. If a resident's email is suppressed and SMS succeeds, the property record still needs correction. If both channels fail, the recovery workflow should require stronger evidence and a separate audit event. Recovery is not a convenient third OTP channel.
When should a team choose SMS, email, or neither?
Choose email OTP when mailbox ownership is already part of the account's trusted lifecycle, the address has recent positive delivery evidence, and the product can manage code generation, expiry, attempt limits, suppression, and verification itself. Choose SMS OTP when the verified phone is the more reliable independent contact for that user and the deployment can enforce country, abuse, and number-change policy.
Choose neither as the sole second factor for high-risk actions. Offer a phishing-resistant factor where the threat model calls for it, and keep OTP as a bounded recovery or compatibility path. A successful code is evidence of control of a channel; it is not evidence that the person is the rightful leaseholder, owner, or employee.
The catch is operational ownership. A team that cannot review bounces, suppress invalid recipients, investigate latency tails, and explain fallback decisions should not add both channels just to increase the number of buttons. Start with one well-instrumented path, run a small rollout across representative properties, and expand only when the evidence supports it.
Top comments (0)