DEV Community

YvesSterling6854
YvesSterling6854

Posted on

How to Assign Seller Login OTP Templates: SMS and Email Delivery Governance

Short answer: assign the authentication team ownership of the login OTP contract, keep the marketplace team responsible for new-order messages, and compare SMS and email using verified-code outcomes rather than send or open events. That boundary keeps a seller's two-factor login safe when the order-notification copy changes, and it gives US and EU rollout decisions a measurable trail.

The operational constraint is template ownership. A seller can receive an order alert and a login code within the same minute, but those messages have different data, retention, and release rules. I once started with a single preferred_channel field because it made the notebook look neat. The first review found that a 403 from the login endpoint could mean an expired challenge, a blocked attempt, or a stale browser state. The field hid all three.

So the experiment is about governance first. The channel is an adapter behind one challenge policy; the template is a contract with one accountable owner. Measure the result before copying the choice to every country.

How should SaaS teams compare SMS and email OTP for US and EU login security?

Start with the account invariant, not a universal channel ranking. If a seller already has a verified mailbox, email can be the first offer. If a maintained phone number is a stronger invariant for that account, SMS can lead. In either case, expose the other channel only when its destination has been verified and its recovery path is documented. A channel switch should be an explicit policy decision, not a silent provider fallback.

Both channels deliver a code that a convincing phishing page can ask a person to type. Calling either one phishing-resistant overstates what the mechanism proves. Keep a short lifetime, cap attempts, invalidate superseded challenges, and never log the code. When the threat model requires phishing resistance, evaluate a cryptographic authenticator instead of stretching an SMS-versus-email comparison.

Deliverability evidence needs the same discipline. DKIM lets a signer associate a domain with a message and lets a verifier validate that signature, as described in RFC 6376. That supports sender authentication; it does not guarantee inbox placement or a completed login. Apple Mail Privacy Protection can hide whether a recipient opened an email, so an open event is not a reliable conversion label. For SMS, a terminal transport state is also only transport evidence. The product outcome is challenge_verified.

I am not sure which channel will win for a particular seller population before its baseline is measured. Country mix, address quality, and recovery behavior can move the result. Your mileage may vary.

What belongs to the login template, and what belongs to the order message?

Make ownership reviewable in code. Authentication owns the OTP wording, allowed variables, expiry language, and localization review. Marketplace orders owns the new-order wording, item title, order reference, and fulfillment link. A shared brand review is fine; shared release authority is not.

This matters because a locked login screen is a hostile data boundary. An order template may contain a product title supplied by a seller or buyer. That value has no business entering an OTP template, even if both messages use the same delivery adapter. The smallest useful registry makes an accidental cross-over fail before a message is sent:

Template Owner Allowed fields Product success event
auth.login_otp authentication code, expiry hint challenge verified
orders.new_order marketplace orders order reference, item title order workflow opened

Keep it boring.

from dataclasses import dataclass
from typing import Mapping


@dataclass(frozen=True)
class Template:
    owner: str
    fields: frozenset[str]


TEMPLATES = {
    "auth.login_otp": Template(
        owner="authentication",
        fields=frozenset({"code", "expires_in_minutes"}),
    ),
    "orders.new_order": Template(
        owner="marketplace_orders",
        fields=frozenset({"order_reference", "item_title"}),
    ),
}


def render(template_name: str, owner: str, values: Mapping[str, str]) -> str:
    template = TEMPLATES[template_name]
    if template.owner != owner:
        raise PermissionError("template owner mismatch")
    if set(values) != template.fields:
        raise ValueError("template fields do not match the contract")
    if template_name == "auth.login_otp":
        return (
            f"Your login code is {values['code']}. "
            f"It expires in {values['expires_in_minutes']} minutes."
        )
    return f"New order {values['order_reference']}: {values['item_title']}"


print(render(
    "orders.new_order",
    "marketplace_orders",
    {"order_reference": "ORD-1842", "item_title": "Blue mug"},
))
Enter fullscreen mode Exit fullscreen mode

The example uses a harmless order reference. Production logs should not print a real OTP or a fully rendered authentication message.

The catch is organizational: this registry is not suitable as a substitute for an approval workflow. It tells you who may render a template, not who may change a destination, rotate a signing key, or approve a locale. Put those decisions in code review and an audit record.

Can one Python state machine make both channels measurable?

Yes, if delivery adapters stay dumb. Issuance, expiry, attempt limits, and replay prevention belong in one service. An adapter receives a rendered message and returns a provider identifier; it does not decide whether a submitted code is valid.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import secrets
from typing import Callable


@dataclass
class Challenge:
    account_id: str
    channel: str
    destination: str
    digest: str
    expires_at: datetime
    attempts_left: int
    verified: bool = False


class OtpService:
    def __init__(self, signing_key: bytes) -> None:
        self.signing_key = signing_key
        self.challenges: dict[str, Challenge] = {}

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

    def issue(
        self,
        account_id: str,
        channel: str,
        destination: str,
        send: Callable[[str, str], str],
    ) -> tuple[str, str]:
        if channel not in {"email", "sms"}:
            raise ValueError("unsupported OTP channel")
        challenge_id = secrets.token_urlsafe(18)
        code = f"{secrets.randbelow(1_000_000):06d}"
        self.challenges[challenge_id] = Challenge(
            account_id=account_id,
            channel=channel,
            destination=destination,
            digest=self._digest(challenge_id, code),
            expires_at=datetime.now(timezone.utc) + timedelta(minutes=5),
            attempts_left=5,
        )
        provider_message_id = send(destination, code)
        return challenge_id, provider_message_id

    def verify(self, challenge_id: str, submitted_code: str) -> bool:
        challenge = self.challenges.get(challenge_id)
        if challenge is None or challenge.verified:
            return False
        if datetime.now(timezone.utc) >= challenge.expires_at:
            return False
        if challenge.attempts_left <= 0:
            return False
        challenge.attempts_left -= 1
        accepted = hmac.compare_digest(
            challenge.digest,
            self._digest(challenge_id, submitted_code),
        )
        if accepted:
            challenge.verified = True
        return accepted


def notebook_sender(destination: str, code: str) -> str:
    assert destination and len(code) == 6
    return "message-for-local-evaluation"
Enter fullscreen mode Exit fullscreen mode

The in-memory store is useful for a notebook-to-prod rehearsal, but it is not suitable for multiple workers or durable audit. Replace it with an expiring shared record and an atomic decrement for attempts. On resend, invalidate earlier active challenges for the same account and purpose; otherwise a delayed first message can authenticate after a second code is issued. Test that invariant without either channel adapter.

Which evidence should decide conversion and deliverability?

Instrument a common event vocabulary before running a US/EU comparison: challenge_issued, provider_accepted, transport_terminal, challenge_verified, challenge_expired, and recovery_started. Store a pseudonymous account key, channel, country bucket, template version, policy version, and provider adapter. Do not store message contents or raw destinations.

Measure twice.

For example, imagine a seller requests an email code, waits, taps resend, and then verifies the second code. A dashboard that counts the first provider acceptance as a success will credit email even if the seller completed through the second message. A dashboard that counts an Apple Mail open may credit a message before the seller has seen it at all. I would trace both challenge identifiers, keep the first one marked superseded, and attribute conversion only to the verified challenge. The same trace should show a transport terminal state, the country bucket, and the template version without exposing the destination. If a support agent later asks why the seller recovered through SMS, the audit trail can answer with states and timestamps rather than a copied code. This is the sort of boring detail that makes an experiment portable: the adapter can change, while the meaning of success stays fixed.

Report four separate views. Transport deliverability is terminal transport outcomes divided by accepted messages. Conversion is verified challenges divided by issued challenges, with time-to-verify percentiles. Security is rejected attempts, expirations, resends, recovery starts, and rate-limit events. Cost is actual charges divided by verified challenges, including resend traffic and any fixed commitment allocated by the finance policy.

Do not use email opens as a login success proxy, and do not treat a transport receipt as proof of a verified code. Those shortcuts make one channel look healthier because its instrumentation is noisier.

The decision record should state the baseline volume, minimum detectable effect, country segmentation, and a stop rule before the experiment starts. A fixed global sample threshold is hard to defend without those inputs. If the evidence is inconclusive, keep both adapters behind the same policy and say so; uncertainty is a result, not a reason to rewrite the template contract.

When is this ownership split the wrong fit?

The split is not suitable when one small team genuinely owns both authentication and marketplace messaging and can enforce the same review gates. In that case, two repositories may add friction; one repository with two explicit ownership entries can be enough. Stick with a single shared workflow when the organization cannot staff separate on-call or localization review.

It is also a poor fit when the product requires phishing-resistant login, offline recovery, or a channel that neither email nor SMS can reliably reach. Choose an authenticator-based design or a documented recovery method then. The useful outcome is a policy that names its boundary, its owner, and its evidence—not a claim that one channel is cheapest in every US and EU market in 2026.

References

Top comments (0)