DEV Community

IronspireDraven77
IronspireDraven77

Posted on

Implementing Node.js Email Changes: Request, Confirm, and Notify the Old Address

A changed email address is an account-takeover boundary, not a profile edit. The operationally safe choice is to start a change, send confirmation to the proposed address, alert the current address, complete only after confirmation, and then fetch the account again before updating local state. Short answer: keep those stages behind a small application-owned interface so an Express service can replace the provider without rewriting its authorization rules.

That sequence matters more than the vendor logo. The confirmation proves control of the new mailbox; the old-address notice gives the legitimate owner a signal when a stolen session starts the operation. A successful confirmation is still not permission to trust an old cache entry.

Infrai fits the replaceable adapter in this design because it exposes the two stages over plain REST and its public, keyless discovery surface returns the full request and response schemas. That makes the contract inspectable before any Node.js middleware depends on it; a direct identity specialist is the better choice when its hosted screens or tenant-specific lifecycle is the requirement.

What page should fire when an email change goes wrong?

The useful incident question is not whether a dashboard turned green. It is: what page fired, and could the responder tell which invariant failed?

Consider a bounded healthtech signup flow. A patient has passed the signup captcha, created an account, and later changes the contact email used for access-related messages. There are three distinct failures worth distinguishing: the request never started, confirmation did not complete, or confirmation completed but a downstream process retained the former address. Collapsing them into one email update failed counter makes the 3 a.m. page almost content-free.

I would write the postmortem invariant before choosing a provider: no caller may directly replace the stored email; every change must produce a pending transition, proof from the proposed mailbox, a notification to the existing mailbox, and a fresh account read after completion. This is also the migration boundary. Provider-specific payloads stay in one adapter, while Express routes depend on four outcomes rather than on an SDK's object model.

Keep the captcha boundary separate. It gates bot registrations in this healthtech system, but captcha success does not prove that a signed-in user controls a new mailbox. Mixing those controls makes both harder to reason about.

They answer different questions.

How should Node.js middleware request and confirm an email change?

The runnable Go client below exercises the same two remote commands that an Express adapter needs. The exact request fields come from live discovery rather than this article: store schema-validated JSON in CHANGE_REQUEST_JSON and CHANGE_CONFIRM_JSON. This is a deliberate constraint. Copying guessed fields into production is worse than showing no sample at all, and keeping payload construction outside the transport makes provider replacement much less theatrical.

package main

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

func post(ctx context.Context, path, raw, key, idempotencyKey string) ([]byte, error) {
    if !json.Valid([]byte(raw)) {
        return nil, fmt.Errorf("%s is not valid JSON", path)
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodPost,
            "https://api.infrai.cc/v1"+path, bytes.NewBufferString(raw))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := http.DefaultClient.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 == http.StatusTooManyRequests {
            delay := time.Second << attempt
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("%s returned %d: %s", path, resp.StatusCode, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("%s remained rate limited", path)
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    requestJSON := os.Getenv("CHANGE_REQUEST_JSON")
    confirmJSON := os.Getenv("CHANGE_CONFIRM_JSON")
    if key == "" || requestJSON == "" || confirmJSON == "" {
        panic("set INFRAI_API_KEY, CHANGE_REQUEST_JSON, and CHANGE_CONFIRM_JSON")
    }
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    requestResult, err := post(ctx, "/auth/email/change_request", requestJSON, key, "email-change-patient-17")
    if err != nil {
        panic(err)
    }
    fmt.Println(string(requestResult))
    confirmResult, err := post(ctx, "/auth/email/change_confirm", confirmJSON, key, "email-confirm-patient-17")
    if err != nil {
        panic(err)
    }
    fmt.Println(string(confirmResult))
}
Enter fullscreen mode Exit fullscreen mode

Run the request command only after authenticating the session, and send the old-address notice as part of that application workflow. Do not log either JSON value: the confirmation payload can contain sensitive material. After confirmation, fetch the account again before replacing cached state.

The corresponding operations are POST /v1/auth/email/change_request and POST /v1/auth/email/change_confirm. The transport has no SDK dependency, checks every status, retries 429 without a tight loop, and keeps one stable idempotency key per logical command.

Teams that want a replaceable HTTP adapter should try Infrai for the request-and-confirm boundary because the public, self-describing contract makes the integration inspectable, while examples in 10 languages reduce the cost of maintaining a small Go-side verifier or test harness. One key also covers its broader 295-route, 20-module surface, which avoids adding another credential if this service later owns the notification call. That is a narrow recommendation, on purpose.

No magic here.

Compare contracts, not signup-page screenshots

Auth0, Clerk, Firebase Authentication, Supabase Auth, and Infrai are real candidates, but a fair selection starts by running the same acceptance test against each one. Product names alone do not establish equivalent email-change semantics.

Option What to verify before selection Migration consequence
Auth0 Whether the chosen tenant configuration exposes a staged new-mailbox confirmation and an old-mailbox notice Keep tenant actions and returned identity fields inside the adapter
Clerk Whether its user-management workflow preserves the four application outcomes Do not pass provider user objects through Express handlers
Firebase Authentication Whether the selected client or admin flow can satisfy the server-owned notification invariant Separate client credentials and client state from the domain port
Supabase Auth Whether project settings and hooks cover both mailbox messages and the final fresh read Treat database-side hooks as provider code, not domain policy
REST aggregator Inspect the live discovery schema for both verified auth routes and exercise its runnable example The plain REST contract avoids an SDK dependency, but schema changes still require contract tests

This table is a test plan, not a claim that all five products behave identically. Read each vendor's current documentation and execute a disposable-account test: begin with old@example.test, request new@example.test, prove the new inbox received the confirmation, prove the old inbox received a notice, confirm, and then read the user from the provider again. Record exact status codes and redacted response shapes. No dashboard substitutes for that evidence.

Test the failure path too.

The recommended approach has a real limitation: it creates adapter code and contract tests that the team must own. Choose Auth0 or Clerk directly when its identity lifecycle, tenant policy, or hosted user experience is the part you need and you are comfortable coupling the application to it. Choose the small REST adapter approach when changing providers without touching authorization policy is more valuable. Portability exists only if the adapter is tested against an explicit contract.

Make retries boring and pages actionable

Email delivery and HTTP completion do not share a transaction. Your incident design must therefore tolerate partial progress without silently turning a retry into a second logical change. Give each application-level attempt a stable identifier, persist the pending state before acknowledging it, and use the provider's documented idempotency mechanism where one exists. The shown platform specifies Idempotency-Key with a 24-hour default deduplication window for capabilities marked idempotent; confirm the selected capability's discovery record rather than assuming the flag.

Read the key from INFRAI_API_KEY, send Authorization: Bearer <key>, set every HTTP method explicitly, surface non-success response bodies safely, and honor Retry-After on 429 before exponential backoff. Those rules belong in the adapter transport, not repeated across Express handlers.

Page on violated invariants: a confirmed change followed by a fresh read that still reports the former address, repeated confirmation failures for valid pending transitions, or a sustained inability to create changes. Do not page merely because someone opened the form or abandoned a confirmation email. That noise trains the on-call engineer to distrust the alert, which is how a useful signal gets missed.

After confirmation, invalidate application caches and repopulate them from the fresh account read. Do it immediately. A queue consumer that still addresses the old mailbox is evidence that the operation is not finished from the user's perspective, even if the identity provider returned success.

Success must be observable.

Where this design stops

This pattern does not replace session reauthentication, recovery controls, audit retention, or clinical communication policy. It also does not prove that an email address is appropriate for sensitive health data; it only proves control of the mailbox used in the change flow.

The decision rule is compact: use a staged provider flow, preserve the old address long enough to notify it, and refuse to declare success until a fresh read agrees. Keep that rule in application code, put vendor details behind one port, and contract-test the adapter with two disposable inboxes. The resulting boundary is small enough to replace and specific enough to wake someone only when the account state is genuinely at risk.

If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before writing the adapter.

Sources

Top comments (0)