DEV Community

SvenNilsson228
SvenNilsson228

Posted on

Marketplace Login Templates — 4 Deliverability, Security, Cost, and Fallback Controls

Use email OTP as the ordinary recovery channel for a marketplace support login, and reserve SMS OTP for accounts whose verified phone number and risk profile justify it. For a US/EU SaaS, neither channel is a phishing-resistant authenticator; the practical win comes from one rate-limited challenge service, deliberate fallback rules, and templates owned by the authentication team rather than by whichever delivery adapter happens to send the message.

That answer is intentionally narrower than "pick the channel with the best deliverability." A marketplace contact form may contain order disputes, seller documents, or payment questions, so routing it to the right support queue starts with knowing which account is speaking. The data flow should stay plain: the login service creates a short-lived challenge, a channel adapter delivers it, the user submits the code, and only a successful verification lets the contact form attach trusted account context before the routing policy selects a queue.

The channel is one control. It isn't the trust boundary.

How do US/EU SMS and email OTP rules govern SaaS login?

Start with the threat model. Email OTP depends on the security of the user's mailbox and the path to it. SMS OTP depends on the phone account, carrier processes, device state, and the public switched telephone network. NIST SP 800-63B treats use of the PSTN for out-of-band authentication as restricted and tells verifiers to consider indicators such as SIM changes and number porting. The same publication says email must not be used as an out-of-band authenticator under its digital identity rules. Those are important boundaries for regulated assurance claims, even when a product team still uses email codes for low-risk login or account recovery outside a NIST assurance program.

Neither option stops a convincing real-time phishing proxy. A user can relay a six-digit code just as readily as a password. For administrators, payout changes, recovery-factor replacement, or access to sensitive seller records, use a phishing-resistant method such as WebAuthn rather than trying to make a message channel carry more assurance than it can.

Start there.

For the ordinary support form, make the choice from evidence already attached to the account. Email is a reasonable default when every account has a verified mailbox, the product has established domain authentication, and users may not have a stable mobile number. SMS can be the primary challenge when the phone is already a meaningful, verified account attribute and users need to authenticate away from email. The catch is roaming, recycled numbers, carrier filtering, and SIM-swap exposure. Email has its own catch: forwarding rules, shared inboxes, spam placement, and a compromised mailbox can all defeat the intended possession check.

Region alone should not flip the channel. US and EU traffic crosses different carrier and privacy regimes, but a country code is weak evidence of user context. Store the user's chosen and verified factors, the reason a fallback was allowed, and the template version that was sent. Minimize phone and email data in logs, set retention deliberately, and involve counsel before turning telemetry into a geographic compliance claim.

I wouldn't use delivery opens as the deciding signal. Apple Mail Privacy Protection can prevent senders from learning Mail activity accurately, so an "opened" event is not dependable proof that the recipient saw an OTP. Measure challenge completion instead: requested, accepted by the delivery service, verified, expired, rejected, or replaced by an approved fallback. I'm not sure any global completion target is honest without your audience mix; a route-level baseline split by country, channel, client, and template version will resolve that uncertainty.

Model the challenge in 4 states before writing code

A notebook version of OTP often starts as generate_code() plus send_message(). Production needs a small state machine around those two calls. The example below keeps that state in memory so the policy is visible and runnable; replace the dictionaries with atomic, expiring storage before deployment. The delivery adapters receive an already selected template and destination. They don't decide authentication policy.

from __future__ import annotations

from dataclasses import dataclass
import hashlib
import hmac
import secrets
import time


OTP_TTL_SECONDS = 300
MAX_VERIFY_ATTEMPTS = 5
MAX_REQUESTS_PER_WINDOW = 3
REQUEST_WINDOW_SECONDS = 600


@dataclass
class Challenge:
    code_digest: bytes
    expires_at: float
    attempts_left: int
    channel: str
    template_version: str
    consumed: bool = False


class OtpPolicy:
    def __init__(self, pepper: bytes) -> None:
        self.pepper = pepper
        self.challenges: dict[str, Challenge] = {}
        self.request_times: dict[str, list[float]] = {}

    def _digest(self, challenge_id: str, code: str) -> bytes:
        message = f"{challenge_id}:{code}".encode()
        return hmac.new(self.pepper, message, hashlib.sha256).digest()

    def issue(
        self,
        *,
        challenge_id: str,
        rate_key: str,
        channel: str,
        template_version: str,
        now: float | None = None,
    ) -> str:
        current = time.time() if now is None else now
        cutoff = current - REQUEST_WINDOW_SECONDS
        recent = [t for t in self.request_times.get(rate_key, []) if t > cutoff]
        if len(recent) >= MAX_REQUESTS_PER_WINDOW:
            raise ValueError("request limit reached")

        code = f"{secrets.randbelow(1_000_000):06d}"
        recent.append(current)
        self.request_times[rate_key] = recent
        self.challenges[challenge_id] = Challenge(
            code_digest=self._digest(challenge_id, code),
            expires_at=current + OTP_TTL_SECONDS,
            attempts_left=MAX_VERIFY_ATTEMPTS,
            channel=channel,
            template_version=template_version,
        )
        return code

    def verify(
        self,
        *,
        challenge_id: str,
        submitted_code: str,
        now: float | None = None,
    ) -> bool:
        current = time.time() if now is None else now
        challenge = self.challenges.get(challenge_id)
        if challenge is None or challenge.consumed:
            return False
        if current >= challenge.expires_at or challenge.attempts_left <= 0:
            return False

        challenge.attempts_left -= 1
        valid = hmac.compare_digest(
            challenge.code_digest,
            self._digest(challenge_id, submitted_code),
        )
        if valid:
            challenge.consumed = True
        return valid
Enter fullscreen mode Exit fullscreen mode

The constants are policy examples, not universal best practices. NIST permits an out-of-band secret to be valid for at most 10 minutes and requires rate limiting when a secret has fewer than 64 bits of entropy. A five-minute lifetime, five verification attempts, and three sends per 10-minute window are stricter local choices that should be evaluated against real completion and abuse data. Don't silently stretch expiry because one carrier is slow; record latency distributions and change the policy through review.

The important implementation details are less flashy than the six-digit formatter. Bind the digest to a unique challenge ID, never store the code in plaintext, compare digests in constant time, consume a successful challenge exactly once, and make issuance plus request-count updates atomic. Rate keys should cover more than an IP address. Use privacy-preserving derivatives of account, destination, device, and network signals so an attacker can't rotate one dimension and keep sending. Responses should avoid revealing whether an account or destination exists, while internal events retain enough reason codes for support and abuse analysis.

One code. One life.

Fallback must also create a new challenge. Invalidate the prior one, re-run authorization for the alternate verified destination, and record why the transition was permitted. Accepting both codes until either expires doubles the active paths into the account and makes incident review muddy.

Rollout starts with versioned authentication templates

The authentication team should own the semantic template: purpose, code placement, expiry wording, locale, sender identity requirements, prohibited links, and a monotonically increasing version. A communications team can own brand language and translations, while delivery specialists own provider-specific rendering. That division prevents a carrier adapter from quietly changing security meaning.

Keep login and recovery templates separate. "Your code is 482901" needs enough context to identify the marketplace and the action, but it should not include a clickable login link that trains users to follow links in authentication messages. Never put order details or the support-form subject in the OTP body. The contact form can be routed after verification; the code message only proves control of the selected factor.

Email authentication belongs in the same release checklist. SPF authorizes sending hosts, DKIM signs messages, and DMARC publishes handling policy plus reporting around aligned identifiers. DMARC does not guarantee inbox placement, nor does it prove that a recipient completed a challenge. It gives domain owners and receivers a standardized policy mechanism. Treat a DMARC policy change like an authentication deployment: stage it, inspect aggregate reports, and coordinate every legitimate sender before enforcement.

Templates need evals too. Snapshot every locale and channel, then assert that the rendered message contains the product identity, action, code placeholder, expiry language, and support guidance while excluding secrets beyond the code and any user-supplied contact-form text. Run those tests before adapter contract tests. Prompt-cost awareness has a parallel here — meter every generated message and retry — but don't use a generative model to improvise authentication copy at send time. Reviewed deterministic templates are cheaper to reason about and far easier to audit.

Versioning closes the loop. Store template_version, locale, policy decision, and channel on the challenge event, without storing the rendered OTP. When completion changes after a copy release, the team can compare versions instead of guessing from provider-level totals. When a support agent sees a report of confusing wording, the version identifies exactly what the user received.

Ownership is the control plane.

Measure completion without trusting delivery proxies

Delivery acceptance is not authentication success. Build a funnel from challenge request to successful verification, and preserve terminal states such as expiration, exhausted attempts, user cancellation, and superseded-by-fallback. Slice it by template version and coarse region, but set minimum sample sizes so a tiny country bucket doesn't send the team chasing noise. Track time to verify as a distribution rather than one average; the slow tail is where short expiry and delayed delivery collide.

Then test failure paths deliberately. A useful eval harness advances a fake clock past 300 seconds, submits a correct code twice, exhausts five wrong attempts, requests a fourth message inside 600 seconds, switches channels, and races two verification requests. Follow one marketplace request all the way through: a seller asks for an SMS code, the first challenge is issued under template seller-login-en-v4, the seller changes to an already verified mailbox, and the policy supersedes the phone challenge before issuing the email challenge. If the old SMS arrives late, verification rejects it without revealing why. If the email code succeeds, the contact form receives an account ID and assurance context, but never receives or logs either code. The queue router can now send a payout question to the account-security queue while keeping the authentication transcript out of the support payload. The expected properties matter more than the exact constants: expired and consumed challenges stay closed, counters update atomically, a fallback supersedes its predecessor, and external responses don't disclose account existence.

Measure completion.

Cost belongs in the policy review, but it shouldn't lead it. Count attempts per completed login, delivery attempts that arrive after expiration, and support contacts caused by channel failure. SMS has a per-message and country-sensitive delivery cost structure; email has sending, reputation, and operations costs even when the marginal message looks small. Use current provider quotes and your own destination mix for a budget model because public rates and carrier fees change. Your mileage may vary — substantially.

Keep fallback narrow. An account with a previously verified email can move from SMS to email after a fresh policy check; an arbitrary address typed during recovery cannot. A high-risk seller changing payout details should stay on a phishing-resistant factor or a reviewed recovery process rather than dropping to the easiest deliverable channel. Conversely, SMS is not suitable when users cannot reliably retain a number, and email is a poor choice when the mailbox is shared by a team. In those cases, stick with passkeys, managed workforce identity, or a documented recovery workflow that matches the account's risk.

The operational checklist is short enough to keep in prose. A release review confirms that issuance and verification are atomic, every code is single-use and expires, repeated issuance and guessing limits apply across relevant identities, fallback revokes the old challenge, templates have owners and tested versions, email domains pass the intended SPF/DKIM/DMARC checks, logs omit codes and raw contact-form content, dashboards join delivery to completion, and elevated marketplace actions require a stronger factor. Rehearse support access as well: an agent should see the reason and template version, not the OTP.

Ship the smallest policy that meets the risk. Then let completion data, abuse signals, and template evals change it through an explicit review, rather than allowing a delivery adapter or a transient cost chart to become the authentication architecture.

References

Top comments (0)