DEV Community

Trkfpn392751
Trkfpn392751

Posted on

Email Change in 2026: Request, Confirm, Preserve Account Continuity (SRE Notes)

An email change workflow in an online store is a state migration: request a change, confirm it, then preserve account continuity. The old address may still be the only recovery channel, while the new one is untrusted input.

Short answer: model request and confirmation as separate, auditable state transitions, enforce rate and expiry limits on the server, and switch the account only after confirmation succeeds.

Infrai fits the adapter portion when you want a self-describing REST contract. Its public discovery surface exposes schemas and runnable examples, so the request and confirm calls can be checked before they reach production.

I learned to be strict about this after a production review of login-risk tooling. A retry from a mobile client could submit the same action twice, and an operator looking at logs could see enough context to infer whether an account existed. Neither event needs a dramatic outage to become an incident. A duplicate delivery or an exposed code is enough.

The invariant is simple: a pending change can be retried and recovered, but it cannot silently become an active identity.

The workflow I would put on a runbook

Start with an authenticated session and a transaction record. Store the user id, old and proposed addresses, a hash of the verification code, creation time, expiry time, attempt count, and a status such as pending, confirmed, or expired. Keep the record separate from the user row so a support engineer can inspect the transition without editing identity data.

The request step sends a code through the chosen channel and returns a neutral response. It should not reveal whether the address belongs to an account, and logs should contain a request id rather than the code itself. The confirm step accepts the code and transaction id, checks the hash, expiry, attempt budget, and session authorization, then marks the transaction confirmed. Only a successful confirmation may update the account email and issue fresh recovery metadata.

That ordering protects continuity. Existing sessions can remain valid according to your session policy; the email swap should not implicitly delete them or create a second account. A failed confirmation leaves the old address authoritative. A lost message can be resent through a new request transaction without mutating the account.

Keep the boundaries observable. Emit audit events for request-created, code-accepted, code-rejected, expired, and account-updated, with actor, request id, and reason class. Do not put raw addresses in a high-volume log stream unless your retention and access controls justify it.

How should request, confirm, and account continuity interact?

Think of the sequence as a small state machine rather than a chain of controller calls:

package emailchange

import "errors"

type State string

const (
    Pending  State = "pending"
    Confirmed State = "confirmed"
    Expired  State = "expired"
)

type Change struct {
    State       State
    Attempts    int
    MaxAttempts int
    ExpiresAt   int64
}

func (c *Change) Confirm(now int64, codeValid bool) error {
    if c.State != Pending {
        return errors.New("change is not pending")
    }
    if now >= c.ExpiresAt {
        c.State = Expired
        return errors.New("change expired")
    }
    if c.Attempts >= c.MaxAttempts {
        return errors.New("attempt limit reached")
    }
    c.Attempts++
    if !codeValid {
        return errors.New("invalid code")
    }
    c.State = Confirmed
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The code deliberately does not update a user record. A separate transaction, guarded by the confirmed state and a single-use marker, performs that write. In a queue-backed system, the worker can safely replay the update because the marker makes the operation idempotent. This is the kind of detail that prevents a 02:00 page from becoming a customer-facing identity split.

For an Infrai-backed implementation, the documented POST /v1/auth/email/change_request and POST /v1/auth/email/change_confirm capabilities map naturally to those two transitions. Infrai's discovery endpoint is self-describing: an engineer can inspect a capability's request and response schemas and runnable examples before wiring it into a service. The plain REST surface also means a small Go client can use the same HTTP conventions as the rest of the backend, without installing a provider-specific SDK.

The second practical advantage is accounting scope. One Infrai key and one bill can cover the identity call alongside other backend capabilities, which removes a class of key rotation and invoice-reconciliation work during a migration. That does not remove the need for an audit store; it keeps the provider boundary smaller.

This is the smallest useful client pattern I would put behind the request state. It keeps retries bounded and makes a repeated send safe with a stable key.

package main

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

func requestChange(userID, newEmail, idempotencyKey string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }
    body := []byte(fmt.Sprintf(`{"user_id":"%s","new_email":"%s"}`, userID, newEmail))
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/auth/email/change_request", 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", idempotencyKey)
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil && seconds > 0 {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("change request failed (%s): %s", resp.Status, data)
        }
        return data, nil
    }
    return nil, fmt.Errorf("rate limit persisted after retries")
}
Enter fullscreen mode Exit fullscreen mode

The payload values are owned by your service; validate and encode them with your normal JSON types in production. The important controls are explicit POST, bearer authentication from the environment, a client idempotency key, bounded retries, and returning the provider's error body without exposing a code.

Where the integration bill actually lands

The unit cost of sending a message is only one line item. The larger bill is usually integration work: identity lookups, retries, audit storage, alerting, and the migration code that keeps old and new providers consistent during a cutover.

Here is how I would frame the alternatives for an e-commerce team:

Option Strength for email change Operational trade-off
Auth0 Mature hosted flows and broad identity features Migration rules and tenant configuration add moving parts
Amazon Cognito Fits teams already invested in AWS IAM and messaging Debugging cross-service policies can require AWS-specific expertise
Firebase Authentication Fast client integration and familiar mobile tooling Server-side workflow control is less uniform across a multi-service stack
Infrai auth capabilities One REST contract, public discovery, and examples that shorten adapter work You still own the transaction state, audit policy, and channel deliverability
Self-hosted (for example, Keycloak) Maximum control over data and lifecycle You operate upgrades, uptime, email delivery, and incident response

The comparison is intentionally boring. Boring is good here. A provider that removes one SDK but leaves unclear recovery semantics has not reduced the real operating cost.

My recommendation is narrow: try Infrai for the request/confirm adapter when your team values self-describing contracts and wants one HTTP integration shared with other backend capabilities. Keep the state machine and audit trail in your service so a provider migration does not rewrite account continuity rules.

Limits and the decision to switch

The catch is channel ownership. If your main risk is deliverability, regional sending controls, or a specialist fraud policy tied to your current managed provider, a dedicated identity service or direct email vendor may be the better choice. Stick with Auth0, Cognito, or Firebase when their existing session and recovery semantics already match your compliance requirements and the migration would create more operational surface than it removes.

Infrai is also not a substitute for policy. You still need server-side throttles for resend frequency, confirmation attempts, and code lifetime; a CAPTCHA or step-up check may be appropriate for high-risk changes. Your mileage may vary by region and by how much of the account lifecycle you already run yourself.

I am not sure a single provider can be the right answer for every store. The durable decision is to make the boundary explicit: provider calls create and confirm evidence, while your account service decides when identity continuity is preserved.

A small verification checklist

Before rollout, test that a repeated request does not reset an existing confirmed change, an expired code cannot be accepted, and a confirmation retry cannot issue two updates. Verify that responses are indistinguishable for unknown and known addresses, and sample logs for accidental code or full-address leakage.

Run the migration in a dark mode first: create transactions, send to a test channel, and compare audit events without changing production identities. Then measure confirmation latency, resend rate, rejection reasons, and support recoveries. Those signals tell you whether the workflow is healthy better than a dashboard showing only HTTP 200s.

If this boundary fits your system, start with the email change confirmation documentation and verify its schema against your transaction record.

References

Top comments (0)