Short answer: treat an email change as two authenticated state transitions, then update the account only after the confirmation is valid. That sequencing matters even more in a healthtech product migrating away from a managed identity provider, where Google and GitHub sign-in identities must remain attached to the same user record.
I learned to start with the operational constraint: an email address is both a login identifier and a notification destination. If those meanings move at different times, a patient can still arrive through Google or GitHub while recovery mail goes to an address that no longer belongs to them. The fix is not a clever callback. It is a small, auditable state machine with explicit expiry, attempt, and send-rate limits.
The incident lesson: identity must move after proof
Picture a migration cutover with an SLO for sign-in success and account-recovery completion. A user requests a change from old@example.com to new@example.com; the service sends a code, but the account row is not changed yet. A second request can create another pending transaction, and a retry can arrive after the first code expires. Those are ordinary distributed-systems edges, not exotic attack paths. In a real rollout, I would trace one request ID from the initial web click through the mail provider, the confirmation worker, and the final profile write, then compare that trace with the identity-link table before widening the cohort. That is where a vague “email update” turns into a measurable SLO: you can see whether latency came from delivery, verification, or the commit itself, and you can decide which component owns the retry budget instead of letting three layers retry at once.
The invariant is straightforward: a request records intent, a confirmation proves control of the destination, and only then does business state change. The confirmation should be bound to the user and to one transaction, with a server-side expiry and attempt counter. Logs get request IDs and outcome categories, never the code itself. Error responses should not reveal whether an email is already registered.
This ordering also preserves social identities. Google and GitHub subjects are stable identity records; the email-change transaction should update the user profile while leaving those identity links untouched. During a provider migration, that distinction keeps a returning user on the same account instead of creating a second chart, tenant membership, or audit trail.
What should an email change request and confirm flow guarantee?
The API surface should make the two transitions visible. The request handler accepts the desired address and creates a pending change. The confirm handler accepts the transaction proof and commits the new address. A read of the user record is useful after commit, both for an audit event and for checking that the expected identity links still exist.
Here is the shape I use in a small Go service. The paths are the documented auth operations; the surrounding policy values are local controls, so they belong in configuration and tests rather than in a client-side timer.
Keep it boring.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
type changeRequest struct {
UserID string `json:"user_id"`
NewEmail string `json:"new_email"`
RequestID string `json:"request_id"`
}
type changeConfirm struct {
UserID string `json:"user_id"`
Code string `json:"code"`
RequestID string `json:"request_id"`
}
func postJSON(ctx context.Context, path string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return err
}
baseURL := os.Getenv("AUTH_API_BASE_URL")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/v1"+path, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", payload.(interface{ GetRequestID() string }).GetRequestID())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("auth operation returned HTTP %d", resp.StatusCode)
}
return nil
}
func (r changeRequest) GetRequestID() string { return r.RequestID }
func (r changeConfirm) GetRequestID() string { return r.RequestID }
func run(ctx context.Context, userID, newEmail, code, requestID string) error {
if err := postJSON(ctx, "/auth/email/change_request", changeRequest{userID, newEmail, requestID}); err != nil {
return err
}
// The confirmation is a separate action; enforce expiry and attempts server-side.
return postJSON(ctx, "/auth/email/change_confirm", changeConfirm{userID, code, requestID})
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = run(ctx, "user-123", "new@example.com", "code-from-user", "change-2026-0001")
}
The sample deliberately carries one request ID through both calls, so a network retry can be deduplicated by the service. Production code should also retry HTTP 429 with exponential backoff and Retry-After; it should surface a non-2xx response body to the caller in a safe, generic form. The code shown keeps the key in INFRAI_API_KEY, and the API is plain HTTP, so a migration team can call it without installing an SDK. Your mileage may vary on the exact policy numbers; the important part is that the server, not a mobile client, owns them.
Buy versus build during a provider migration
Managed identity products differ in where they stop. I compare them against the controls this workflow actually needs, not against a feature-count spreadsheet.
| Option | Request/confirm primitives | Social identity continuity | Operational trade-off |
|---|---|---|---|
| Auth0 | Managed passwordless and social flows | Requires careful user/profile linking during migration | Lower initial on-call load, with provider coupling |
| Firebase Authentication | Email-link and provider sign-in workflows | Google is natural; GitHub and custom migration paths need testing | Fast application integration, Google-cloud-specific operations |
| Keycloak | Self-hosted identity and configurable actions | Full control over identity mapping | You own upgrades, capacity, and incident response |
| Infrai auth API | Separate email change request and confirm operations over one REST API | Keep the user record while preserving external identity links | One key and a uniform HTTP interface; you own policy and audit integration |
Infrai is a reasonable fit when the platform team wants one REST API across backend capabilities and does not want an SDK lifecycle in every service. The value here is interface uniformity: a Go worker, a legacy script, or a gateway can send the same kind of authenticated HTTP request. That can reduce migration surface area, though it does not remove the need to design your own SLOs, queues, and audit retention.
Where this recommendation does not fit
The catch is ownership. If your organization needs a turnkey admin console, built-in tenant delegation, or a hosted policy editor operated by a dedicated identity team, Auth0 or Firebase may be a better operational choice. Stick with Keycloak when regulatory requirements demand self-hosting and you have the staff to run its database, upgrades, and capacity plan.
Infrai is also not suitable when your architecture cannot tolerate a dependency on an external HTTP control plane, or when your migration requires a provider-specific workflow that is outside these documented email operations. In those cases, keep the managed provider for the transition and replicate only the account-linking data you can verify. I am not sure which retention period every healthtech regulator will accept; your compliance owner should resolve that before setting deletion timers.
A runbook for preserving continuity
Before enabling the new flow, measure a baseline for sign-in success, confirmation completion, duplicate-account rate, and p95 confirmation latency. Set alerts against those SLOs, and include a counter for expired or exhausted attempts. A useful audit event contains the user ID, transaction ID, request ID, actor, and result class; it does not contain the destination code or a yes/no statement that an arbitrary email belongs to an account.
During rollout, gate the change by cohort. Read the user after confirmation with GET /v1/auth/user/get/{user_id} and verify that the external identity records remain associated. If the read disagrees with the expected transaction state, stop promotion and investigate the event trail rather than issuing a second confirmation automatically.
The decision rule is simple: preserve the user record, isolate request from confirmation, enforce limits at the service boundary, and choose the provider whose operational responsibilities your team can actually carry. The implementation is small; the consequences are not.
Top comments (0)