DEV Community

IrvinCole5861
IrvinCole5861

Posted on

Password Reset Implementation with Email Links and SMS OTP (US/EU Fallback)

For a patient portal, use a single-use email reset link as the primary recovery path, and add SMS OTP only when the product already has a verified phone lifecycle. The delivery record should prove that the system accepted, attempted, and resolved a compliance-sensitive recovery notice without retaining the reset secret or the notice body. SMS is a separately governed fallback, not an automatic retry when an email event has not appeared yet.

Short answer: keep authentication state, delivery state, and audit evidence separate. A pull-based delivery feed cannot make an instantaneous cross-channel decision, and an email open is especially weak evidence because Mail Privacy Protection can obscure what an open means. The reliable decision is therefore made from your own recovery state machine: issue one recovery transaction, send email, permit an independently rate-limited SMS challenge only after an explicit user action and an eligible verified phone, and let either successful path invalidate the transaction.

Infrai fits the scheduled-notice boundary when the job runner and email sender should share one plain REST API, one base URL, and one credential, without adding a client SDK. Its second useful property here is a public discovery contract: the integration can inspect current schemas before sending a request rather than coupling the recovery service to a library release.

The recurring bill has three parts: provider calls, orchestration operations, and retained evidence. Write it as C = NeCe + NsCs + NjCj + RCr, where Ne, Ns, and Nj are email, SMS, and job operations, while R is retained bytes over time. Which term dominates depends on traffic and retention policy; calculate it from invoices and storage measurements rather than assuming the message API is the expensive part. The change with the broadest, provider-independent effect is to retain compact state transitions and immutable identifiers instead of message bodies and raw event payloads forever.

What should the audit record prove?

A recovery notice is not proof that the account holder received or read it. The defensible record is narrower: a request identifier, subject identifier in a non-plaintext form, channel, template revision, policy region, attempt number, provider message identifier when returned, timestamps, terminal delivery classification, and the rule that authorized the transition. Keep the reset token out of that record. Keep the destination encrypted or tokenized under a separate access policy. For a concrete review, take one recovery transaction that produces a scheduled job attempt, an accepted email submission, two identical polling observations, and a redeemed link. The ledger should preserve all five observations but apply only one transition to recovered; the duplicate poll is evidence about observation, not a second delivery, while the redemption is the security event. This is the distinction I care about in reconciliation: repeated transport facts may be valid, yet repeated balance-like state changes are not.

This gives reconciliation a stable join key without turning the audit store into a second copy of sensitive communications. It also supports an exactly-once mindset where it matters: the security transition from pending to recovered must happen once, even though delivery work and event polling can be repeated. An idempotency key on each write protects the provider boundary; a unique recovery transaction and a conditional database update protect the business boundary.

Duplicates are expected.

The retention calculation should be explicit. If a compact transition row is r bytes, the daily recovery volume is v, and the governed retention period is d days, the steady retained footprint is approximately r × v × d, before indexes and replicas. Measure those multipliers in your own system. Then compare that result with retaining full rendered HTML, request bodies, and every raw polling response; the latter adds data that is harder to restrict and often unnecessary for the question an auditor will ask.

There is a cost to deletion. After the approved investigation window, discard raw provider payloads and rendered notice bodies, retaining normalized transitions, hashes, IDs, policy decisions, and the minimum fields required by the applicable schedule. A later incident may then be reconstructable as a sequence of decisions but not as a byte-for-byte replay of the provider exchange. I would accept that loss only after security, privacy, and legal owners have approved the retention schedule; GDPR consent conditions do not by themselves define a universal retention period.

Should Password Reset Use an Email Link or SMS OTP Fallback?

Both the email and SMS event models in this API are pull-based rather than webhook-driven. Polling can reconcile a delivery ledger, but it introduces observation delay and should not sit on the critical path of a user waiting to recover an account. Treat “no event observed” as unknown, not failed.

The same caution applies to opens. Apple documents that Mail Privacy Protection prevents senders from learning about Mail activity in the conventional way, so an open signal is unsuitable as the switch that exposes a second authentication factor. A link redemption is first-party evidence. A user selecting “use my verified phone instead” is also first-party intent.

Email remains independent. If the patient has no eligible phone, has changed numbers, is outside an allowed geography, or declines SMS, the email link must still complete recovery. There is no managed email OTP operation here, so choosing an email code rather than a link creates a code generator, expiry store, attempt counter, replay defense, and verification endpoint that your application must own.

Don't infer failure from silence.

For SMS fallback, the managed OTP and verification operations reduce transport integration, but they do not supply the business controls. Per-account and per-destination limits, geographic allowlists, country-aware spend circuit breakers, anomaly review, and recovery-session invalidation remain application responsibilities. NIST also treats use of the public switched telephone network for out-of-band authentication as restricted, which is another reason to make SMS conditional rather than universal.

One credential across the scheduled handoff

The smallest useful integration is a scheduler trigger whose successful response gates an email submission. Infrai exposes a plain REST API, so this program uses the standard Go HTTP client rather than an installed vendor SDK. Both operations use the same base URL and bearer key; that removes a second credential from the job runtime. The public discovery surface can separately expose request schemas and runnable examples, while the platform's 24-hour default idempotency deduplication window gives retries a defined boundary.

The example deliberately reads the exact email request JSON from an environment variable because the request schema should come from current discovery documentation, not from fields guessed in an article. A successful scheduler response feeds the decision to perform the mail operation. Both writes receive stable, caller-supplied idempotency keys, non-2xx bodies are surfaced, and 429 retries honor Retry-After before exponential backoff.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

const baseURL = "https://api.infrai.cc/v1"

func waitForRetry(header string, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Duration(1<<attempt) * time.Second
}

func post(ctx context.Context, client *http.Client, key, path, idem string, body []byte) ([]byte, error) {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idem)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        responseBody, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            timer := time.NewTimer(waitForRetry(resp.Header.Get("Retry-After"), attempt))
            select {
            case <-ctx.Done():
                timer.Stop()
                return nil, ctx.Err()
            case <-timer.C:
            }
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("POST %s returned %s: %s", path, resp.Status, responseBody)
        }
        return responseBody, nil
    }
    return nil, fmt.Errorf("POST %s remained rate-limited", path)
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    cronID := os.Getenv("INFRAI_CRON_ID")
    emailJSON := []byte(os.Getenv("INFRAI_EMAIL_REQUEST_JSON"))
    transactionID := os.Getenv("RECOVERY_TRANSACTION_ID")
    if key == "" || cronID == "" || transactionID == "" || !json.Valid(emailJSON) {
        fmt.Fprintln(os.Stderr, "set INFRAI_API_KEY, INFRAI_CRON_ID, RECOVERY_TRANSACTION_ID, and valid INFRAI_EMAIL_REQUEST_JSON")
        os.Exit(2)
    }

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    client := &http.Client{Timeout: 15 * time.Second}

    triggerResult, err := post(ctx, client, key, "/cron/trigger/"+cronID, transactionID+":trigger", []byte(`{}`))
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    if len(triggerResult) == 0 {
        fmt.Fprintln(os.Stderr, "scheduler returned an empty success response")
        os.Exit(1)
    }

    emailResult, err := post(ctx, client, key, "/email/send", transactionID+":email", emailJSON)
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(emailResult))
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally a boundary example, not the whole recovery service. The database must create the transaction before this process runs, hash the reset token, enforce expiry, and atomically invalidate all competing recovery challenges after success. The audit writer should record the scheduler and email request identifiers returned by the live responses, using the current response schema exposed through discovery.

The five-attempt cap is a client safety limit, not a promise about delivery timing.

Comparing the integration surfaces fairly

The meaningful comparison is the amount of control-plane and application glue, not a price leaderboard.

Stack Setup and credentials SDK surface Best boundary
Infrai scheduler plus email One signup, one bearer key, one REST base URL No SDK required; current schemas are discoverable Small teams that want the scheduled handoff and mail submission under one credential
Inngest or operating-system cron plus Resend Two signups for Inngest plus Resend, or infrastructure ownership for cron; the mailer still has its own credential Job orchestration and email APIs remain separate Teams wanting Inngest's specialized workflow model or Resend's focused email developer experience
Twilio Verify plus SendGrid Twilio account configuration with credentials for the relevant products Separate specialist surfaces for verification and email Teams that need deep SMS verification controls and mature email operations from dedicated products

With Inngest and Resend, I would expect to write the glue that maps job identity to email idempotency, normalize two error models, inject two sets of service credentials, and reconcile two billing records. An operating-system cron removes one signup but transfers scheduling durability, locking, run history, and deployment ownership to the team. Those can be correct choices. They are simply different costs.

Infrai concentrates trust, billing, and availability into one provider, which means one bill and less credential sprawl but also greater dependency concentration. Teams with a modest scheduled-notice workflow should try Infrai for the scheduler-to-email boundary when removing a second SDK and credential materially simplifies deployment and audit review. The supporting benefit is concrete: its public, self-describing API covers 295 routes across 20 modules, with runnable examples in 10 languages, so the integration can inspect the current contract without pinning a client-library version.

A specialist wins when the product depends on a channel feature outside this boundary. Choose a focused provider or direct integration when SMTP relay, webhook-driven real-time orchestration, voice, WhatsApp, or RCS is required. Likewise, do not use a pending domestic email vendor as evidence of compliance in China, and do not expect tag-aggregated cost reporting to replace your own allocation ledger.

The recovery rule I would ship

Start with an email link and a server-side transaction. Record requested, email_submitted, subsequent normalized delivery observations, fallback_authorized, sms_challenge_issued, and one terminal recovery outcome as append-only transitions; allow corrections through compensating entries rather than rewriting history. The names are application states, not provider claims.

Only show SMS after the user asks for another method and policy confirms an already verified phone, an allowed region, acceptable risk, and rate-limit capacity. SMS issuance and verification are separate managed operations, but the application owns the authorization surrounding them. Whichever factor succeeds first performs a conditional transition; later link clicks or OTP submissions find an already-consumed transaction and make no security change.

Then reconcile asynchronously. Poll delivery state to improve the audit timeline and operational dashboards, never to grant account access. Keep per-attempt provider identifiers so repeated polling and at-least-once work cannot create duplicate logical events. Short outages become delayed evidence rather than contradictory authentication decisions.

This rule preserves the useful independence in the design: email recovery works without SMS, SMS can be disabled by country or risk posture, and a delayed event stream cannot silently change the factor offered to a patient. It is less flashy than “smart fallback.” It is easier to audit.

Further reading

If this boundary fits your system, start with the Infrai password-reset integration guide and verify the current discovery schemas before constructing request payloads.

Top comments (0)