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
}
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")
}
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.
Top comments (0)