DEV Community

JerichoRhodes5847
JerichoRhodes5847

Posted on

Login OTP Fallbacks: Email Verification Codes and Magic Links When SMS Fails

Short answer: use email as a deliberate fallback for a login OTP, but choose a magic link or a code you own end to end; email is slower than SMS, and the delivery service will not manage the OTP lifecycle for you.

For a marketplace seller waiting to acknowledge a new order, this distinction matters. The notification may be ordinary email, while the seller's login recovery path is security-sensitive. Treating both as the same “send a message” operation leaves the hard parts—expiry, replay, throttling, and regional policy—in the application where they belong.

Cost and retention: what are you willing to keep?

The dominant cost in an email fallback is usually retention and support work, not the act of submitting one message. Every code creates state: a hash, an expiry timestamp, an attempt counter, a purpose, and a record that lets the next request invalidate the previous one. Keeping raw codes, message bodies, or event history longer than the support case needs increases the consequence of a database leak and makes deletion requests harder to honor in the EU.

I start with a short-lived record: hash a six-digit code with a server-side pepper, set a narrow expiry, cap attempts, and mark it consumed in the same transaction as a successful login. Store a challenge identifier rather than putting the code in logs. A resend should replace the old challenge, not create two valid paths. Those choices move the large term in the bill—the amount of retained authentication data and the operational time spent explaining duplicates—without pretending that email has SMS latency.

The retention decision has a real cost. If you discard delivery metadata immediately, a seller who says “I never got the code” gives support less to inspect. Keep a minimal audit record for the period your security and privacy teams approve, then delete or aggregate it. There is no free observability here.

That trade is easy to miss during a launch review.

Imagine the seller receives a new-order email at 09:00, requests a login fallback at 09:02, and taps resend twice because the first message is filtered. Three independent records now exist unless the challenge store collapses them: the order notification, the first login challenge, and the replacement challenge. A support dashboard that joins them by recipient alone can show a false “delivered” signal, while a privacy export can expose more history than the seller needs. Bind every record to a purpose and challenge identifier, keep the notification path separate from the authenticator path, and define deletion for both before production. The extra schema work is cheaper than explaining a replay or an accidental account lock.

What should a US or EU login OTP fallback use when SMS is unavailable?

For a US or EU marketplace, pick the channel after checking the account's existing trust. A verified mailbox that the seller already uses is a reasonable recovery factor; a brand-new address collected during the lockout is not. Email verification code versus magic link is a product choice, not a property of the transport.

A code is better when the user is switching devices, using an embedded webview, or needs to read a value into a terminal or native app. A magic link removes typing and usually produces a lower-friction fallback, but it needs careful handling of link previews, browser handoff, and one-click consumption. Make the token single-use, bind it to the login attempt, and show a confirmation page rather than silently changing account state when a mail scanner opens it.

Email events are pull-only in this capability. That means a worker must poll the event-list route and accept that “sent,” “delivered,” and “opened” are not a real-time control plane. If the login screen promises an immediate fallback, the promise is wrong. I’m not sure a given mailbox provider will expose a useful delivery signal at all; test the providers your US and EU users actually use.

Scheduled email reminders are another trap: a scheduled send cannot be canceled here, while SMS cancellation is available. Do not schedule a security reminder that might outlive the login attempt. Send only after the challenge is created, and expire the challenge independently of whatever remains in the mail queue.

Owning the code lifecycle instead of outsourcing it

There is no hosted email OTP API in this capability. Your service must generate the code, store only a hash, enforce expiry, count failed attempts, and verify the purpose and session binding. The email send route is a transport call, not an authenticator.

Keep it boring.

Here is the smallest transport wrapper I would put behind that state machine. It deliberately reads the base URL and key from the environment, gives the send a client id for retry safety, honors Retry-After, and refuses to treat a non-2xx response as success. The application still owns code creation and verification; this function only sends the already-rendered message.

import os
import time
import requests


def send_login_email(recipient: str, subject: str, body: str, idempotency_key: str) -> dict:
    base_url = os.environ["INFRAI_BASE_URL"].rstrip("/")
    api_key = os.environ["INFRAI_API_KEY"]
    payload = {"to": recipient, "subject": subject, "text": body}
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Content-Type": "application/json",
        "Idempotency-Key": idempotency_key,
    }

    for attempt in range(4):
        response = requests.request(
            method="POST",
            url=f"{base_url}/v1/email/send",
            json=payload,
            headers=headers,
            timeout=10,
        )
        if response.status_code != 429:
            response.raise_for_status()
            return response.json()
        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2 ** attempt
        time.sleep(delay)

    raise RuntimeError("email send rate limit did not clear")
Enter fullscreen mode Exit fullscreen mode

The idempotency key should be stable for a retry of the same challenge, not freshly generated by every caller; persist it beside the challenge and pass it back into this function in production. That is the difference between retrying a request and accidentally sending two codes.

For the seller-order scenario, the sequence is straightforward: create a pending login challenge; render a small, localized template; send the message through POST /v1/email/send; and let a background worker poll GET /v1/email/event/list for operational evidence. Keep the verification transaction independent from polling. A delayed event must not extend the code's life.

Use a template system with explicit escaping. Mustache's manual is a useful baseline because it documents variable interpolation and the absence of arbitrary code execution, but it does not solve phishing resistance or localization. Put the order identifier in the notification email, not in the login token, and avoid including sensitive seller data in a message that can be forwarded.

Rate limits belong at several boundaries: per account, per destination, per device or network, and per challenge. Add a country-aware spend and abuse circuit in your application; the messaging layer does not provide a geographic fence that can make that decision for you. SMS remains the faster primary factor when it is available, but it is not automatically the stronger one, so keep recovery and support escalation explicit.

How do the practical options compare for a seller login?

The table is intentionally about ownership and failure modes, not a vendor scorecard.

Option Template and token ownership Feedback loop Best fit Main limitation
SMS OTP through Twilio Verify Provider-managed challenge and message templates Provider webhooks and status APIs Primary login factor when a phone is reachable SIM-swap and country-policy exposure; phone numbers are not universal
Email API through SendGrid App owns the code; provider renders and delivers mail Delivery events are available, but authentication state is still yours A broad email fallback with familiar template tooling Mailbox delay, spam filtering, and extra lifecycle code
Passwordless email through Auth0 Hosted magic-link flow and session policy Identity platform events and logs Teams that want an identity boundary outside the marketplace Less control over the exact seller-facing template and state model
A direct email transport behind one REST API App owns code or link; transport is a plain HTTP call Pull-only email events in this capability A small stack that wants one key and no SDK to install No managed email OTP, no SMTP relay, and no webhook push

The fourth row describes Infrai's useful boundary without turning it into a recommendation for every team. Infrai's concrete advantage here is one REST API: plain HTTP, no SDK, any language. A service that already has an HTTP client need not add a client library just for email transport. That simplicity is valuable when the same backend also has other capabilities, but it does not remove the security state described above.

Infrai also uses one key across the email and SMS capabilities: the seller workflow can keep its transport credentials in one place while the application decides which factor is primary. That reduces credential plumbing when a team later adds SMS, but it does not turn either channel into a managed authenticator.

The catch is template ownership. If the security team wants a managed authenticator with a documented policy surface, stick with a product such as Twilio Verify or Auth0. If the marketplace must control copy, retention, and the exact handoff between a new-order notification and login, an app-owned code or magic-link flow is the more honest design. It is not suitable when you need webhook-driven orchestration or a provider-managed OTP challenge.

A decision rule that survives the next incident

Make SMS the primary route only when the account has a usable, verified number and your country controls are ready. Offer email as a fallback only after confirming a previously verified mailbox. Prefer a magic link for a browser-first seller console; prefer a code for cross-device and native flows. In both cases, invalidate on use, expire aggressively, and keep the email message free of secrets beyond the one-time token.

When a seller reports a missing message, support should be able to see the challenge state without seeing the code: created, send requested, expired, consumed, or locked after attempts. That small state machine is more valuable than a promise of “instant” email. It also gives the team a clean place to add a stronger factor later rather than making the mailbox a permanent exception.

References

Top comments (0)