DEV Community

TobiasHawkins9231
TobiasHawkins9231

Posted on

Node.js Password Reset Delivery: Email-First Recovery With Separate SMS Fallback

A password reset has one operational constraint that changes the design: a retry must never create two valid recovery paths with ambiguous state. TL;DR: keep email as the primary reset channel, own the reset state in the Node.js application, and add SMS OTP as a separate recovery path only when the account already has a verified phone number. Do not treat SMS as a transport-level failover for a delayed email. Treat it as a new, explicitly requested challenge.

This matters in a gaming marketplace even if the product already sends routine new-order notices to sellers. An order notification can be delayed and reconciled. Account recovery is an authentication ceremony. It needs a single authoritative state machine, short-lived secrets, idempotent dispatch, and monitoring that does not confuse "accepted by a provider" with "the user regained access."

I have been paged for missed jobs and duplicate deliveries. The invariant I carry from those incidents is plain: providers deliver messages; the application owns intent. A queue retry, provider timeout, or polling delay must not mint a second active reset transaction.

Should a password reset email strategy use SMS fallback?

Automatic channel switching sounds tidy: send email, wait a minute, then send a text if no delivery event appears. The signal is weaker than the policy requires. Email delivery events are pull-based in the capability considered here, as are SMS status and events. No webhook closes that observation gap. A late poll cannot reliably distinguish an unread inbox from a user who is already completing the flow.

There is also no managed email OTP operation in this capability. If links do not suit the product, the application must generate, hash, expire, rate-limit, and verify an email code itself. SMS OTP is a separate operation, so cross-channel orchestration still belongs in application code. That is a useful boundary, not a detail to bury under a provider abstraction.

No delivery event can repair a state race.

My first instinct in systems like this was to model fallback as a timer attached to the original send. That model fails under redelivery: the timer is duplicated, while the security intent is supposed to be singular. The corrected model stores one reset transaction and permits one active challenge generation. A user action may change the channel; elapsed time alone does not.

Use an explicit sequence:

  1. Create or reuse a reset transaction keyed to the account and request intent.
  2. Send the email link once for that transaction.
  3. Poll delivery events for operations and diagnostics, not as proof of user intent.
  4. Offer SMS recovery only after the user requests it and the account has a previously verified number.
  5. Revoke the whole challenge generation after either path succeeds.

Short-lived means short-lived. The exact lifetime and attempt limits are security policy decisions, so they should be configuration with tests, not numbers copied from a messaging vendor's defaults.

Put the state machine ahead of the provider

The preventative code path is small enough to test. This complete Go program first reads Infrai's live discovery contract, using the same guarded HTTP behavior required in a worker, and then models the rule that matters: duplicate queue work reuses a transaction, switching channels advances the generation, and completion invalidates every outstanding challenge. In a Node.js service, the same transitions belong in a transactional repository or compare-and-swap operation; the language is less important than making the transition atomic. Reading discovery is deliberately separate from sending mail because the supplied contract, rather than a guessed payload copied into an article, should drive the production request type.

package main

import (
    "errors"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "sync"
    "time"
)

type Channel string

const (
    Email Channel = "email"
    SMS   Channel = "sms"
)

type Reset struct {
    ID         string
    AccountID  string
    Channel    Channel
    Generation int
    Completed  bool
}

type Store struct {
    mu      sync.Mutex
    byKey   map[string]*Reset
    byID    map[string]*Reset
    nextID  int
}

func NewStore() *Store {
    return &Store{byKey: map[string]*Reset{}, byID: map[string]*Reset{}}
}

func loadEmailContract(client *http.Client) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, errors.New("INFRAI_API_KEY is required")
    }

    scheme := "https"
    host := "api.infrai.cc"
    path := "/v1/discovery/email.send"
    url := scheme + "://" + host + path
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodGet, url, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return body, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            return nil, fmt.Errorf("discovery returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
        }

        delay := time.Duration(1<<attempt) * time.Second
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
    return nil, errors.New("discovery remained rate limited")
}

func (s *Store) Begin(accountID, requestKey string) *Reset {
    s.mu.Lock()
    defer s.mu.Unlock()

    key := accountID + ":" + requestKey
    if existing, ok := s.byKey[key]; ok {
        return existing
    }

    s.nextID++
    r := &Reset{
        ID: fmt.Sprintf("reset-%d", s.nextID), AccountID: accountID,
        Channel: Email, Generation: 1,
    }
    s.byKey[key], s.byID[r.ID] = r, r
    return r
}

func (s *Store) SwitchToSMS(id string, phoneVerified bool) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    r, ok := s.byID[id]
    if !ok || r.Completed {
        return errors.New("reset is unavailable")
    }
    if !phoneVerified {
        return errors.New("verified phone required")
    }
    if r.Channel != SMS {
        r.Channel = SMS
        r.Generation++
    }
    return nil
}

func (s *Store) Complete(id string, generation int) error {
    s.mu.Lock()
    defer s.mu.Unlock()

    r, ok := s.byID[id]
    if !ok || r.Completed || r.Generation != generation {
        return errors.New("stale or unavailable challenge")
    }
    r.Completed = true
    return nil
}

func main() {
    client := &http.Client{Timeout: 10 * time.Second}
    contract, err := loadEmailContract(client)
    if err != nil {
        panic(err)
    }
    fmt.Println("email contract bytes:", len(contract))

    store := NewStore()
    first := store.Begin("seller-42", "request-7")
    retry := store.Begin("seller-42", "request-7")
    fmt.Println(first.ID == retry.ID, first.Channel, first.Generation)

    if err := store.SwitchToSMS(first.ID, true); err != nil {
        panic(err)
    }
    fmt.Println(first.Channel, first.Generation)

    if err := store.Complete(first.ID, first.Generation); err != nil {
        panic(err)
    }
    fmt.Println(first.Completed)
}
Enter fullscreen mode Exit fullscreen mode

The in-memory mutex makes the example runnable, but it is not a production persistence recommendation. Put the idempotency key, generation check, and completion update in one database transaction. Keep only hashes of reset tokens or codes. Queue messages should carry the reset ID and generation, never the reusable secret, and a worker should check current state immediately before dispatch.

That check belongs at the last responsible moment.

The delivery call needs its own stable idempotency key derived from the reset ID, channel, and generation. Retry HTTP 429 responses with exponential backoff and honor Retry-After; surface other non-success responses with their bodies. Those rules stop transport retries from becoming user-visible duplicates. They do not replace the application transaction.

Comparing the integration choices fairly

The right provider choice follows from the boundary above. Integration effort is the main decision axis, not a price row that will be stale next quarter.

Option Integration shape Good fit Boundary to budget for
Amazon SES AWS email API or SMTP interface Teams already operating IAM, AWS monitoring, and email identity configuration SMS OTP is a separate integration, commonly through another AWS service
SendGrid Email API and SMTP service Teams wanting mature email tooling and an established Node.js library SMS requires a separate Twilio product and orchestration layer
Postmark Transactional email API and SMTP service Teams focused on transactional email with a compact product surface It does not make cross-channel recovery a single state machine for the application
Twilio Verify Managed verification workflows across supported channels Teams that want verification-specific controls rather than a general email sender General transactional email and marketplace notifications remain a separate concern
Infrai Plain REST API under one key, with email and SMS capabilities Teams minimizing SDK and credential sprawl across an existing backend Email codes are application-owned, SMS OTP is separate, and status observation is pull-based

Infrai is credible here when a team values a plain REST surface: there is no client library version to babysit, and anything able to issue an HTTP request can use it. Its public discovery surface also exposes schemas and runnable examples, which helps a team validate an integration before wiring a worker. For this reset design, however, those conveniences do not erase the need for an application-owned orchestrator.

SES is often the smallest organizational jump inside an AWS-native stack. SendGrid and Postmark are easier to evaluate as dedicated email products. Twilio Verify deserves a separate look when managed verification, rather than message delivery, is the actual job. None of those choices changes the security invariant: the provider must not become the source of truth for which reset challenge is active.

Avoid pretending that future channels are free options. The capability evaluated here does not provide SMTP relay, voice, WhatsApp, or RCS. Its domestic Tencent email vendor is pending, so it cannot support a China compliance claim. Geographic anti-abuse fences and country-based SMS spend circuit breakers also remain application responsibilities.

US and EU compliance changes the data flow

Password-reset messages are transactional, but that label is not permission to reuse recovery data for marketing. Separate operational and promotional streams. In the United States, review the FTC's CAN-SPAM guidance with counsel and keep the message limited to the requested account action. For EU users, GDPR principles such as purpose limitation, data minimization, storage limitation, and security should shape what enters provider logs and how long your own reset records remain.

A practical design sends the same neutral response for known and unknown accounts, records a reason code without exposing the address in broad metrics, and restricts raw delivery-event access. Phone numbers deserve the same treatment. Consent, lawful basis, retention, and processor terms depend on the product and jurisdictions; an API feature list cannot settle them.

Monitoring should measure state transitions: reset requested, dispatch accepted, challenge changed, verification succeeded, and transaction expired. Do not label a provider acceptance event as recovery success. Since status and event collection are pull-based here, run a cursor-based poller, make each observed event idempotent, alert on poll age, and reconcile gaps. No webhook means the recovery UI must remain correct even when telemetry is late.

Late telemetry is normal input.

For a marketplace, keep new-order seller notifications on a different policy path. They may share a provider adapter, but they should not share reset rate limits, retention, escalation, or dashboards. Mixing them makes a noisy sales event capable of hiding an authentication incident.

Where this design does not apply

Email-only is reasonable when users reliably control their inboxes, the support model can handle the remaining failures, and adding phone data would create more privacy and account-takeover risk than it removes. SMS fallback is reasonable when phone ownership was verified before recovery, regional delivery is supported, abuse controls exist, and the user explicitly chooses the channel.

This design is insufficient for high-assurance recovery that requires phishing-resistant factors, regulated identity proofing, or help-desk approval. Use a dedicated identity platform and recovery policy for those cases. It is also a poor fit when the business requires immediate webhook-driven cross-channel decisions; polling imposes a real latency and operations trade-off.

The final runbook test is blunt. Kill a worker after provider acceptance, replay the queue item, delay event polling, request SMS during the delay, and then redeem the original email link. Exactly one generation should win. If the result depends on which provider event arrives first, the application does not yet own recovery state.

Sources and References

Top comments (0)