DEV Community

FluxH91
FluxH91

Posted on

Email Change Workflows: Verified Steps for Continuous Gaming Accounts

For a gaming account, treat an email change as a small state machine: request, confirm, then commit the new address while preserving the player’s existing identities and sessions according to your policy. The deciding constraint is continuity. A Google or GitHub sign-in must not turn into a second account merely because a player changed their recovery email.

Short answer: keep the change request and confirmation as separate, auditable transitions, enforce rate and expiry limits on the server, and update the account only after confirmation succeeds.

The invariants that keep a player attached to the right account

The email address is an attribute, not the account itself. In this gaming scenario, the durable identity is the user record plus its linked Google and GitHub identities. The workflow should therefore create a pending change tied to that user, not create a new user when the destination address is first submitted.

Four invariants make the boundary reviewable:

  • A request sends a verification code but does not mutate the canonical email.
  • A confirmation consumes a valid code once; it is bounded by an expiry time and an attempt limit.
  • Only a successful confirmation advances the business state to the new email.
  • Logs and client errors never reveal the code or whether another account owns an address.

Those rules also define recovery. A lost code means issuing a new request after throttling, not accepting a guessed value or silently switching the account. Your exact timeout and retry budget belong in configuration and monitoring; I’m not going to pretend one universal number fits every game.

Keep it boring.

How should a gaming email change workflow request and confirm continuity?

Model the two POST operations as distinct transitions. change_request accepts the proposed destination and starts delivery. change_confirm proves possession of that destination and is the only transition allowed to finalize the change. The existing user can be read with GET /v1/auth/user/get/{user_id} when you need a post-confirmation snapshot, while the linked social identities remain associated with the same user id.

Here is a deliberately small Python client. It leaves request fields to the server’s published schema, uses an environment-held key, gives every write an idempotency key, and backs off on rate limiting. In production, bind the idempotency value to the user and pending-change record so a network retry cannot apply a second transition.

import json
import os
import time
import uuid

import requests

BASE_URL = os.environ["INFRAI_BASE_URL"].rstrip("/") + "/v1"
API_KEY = os.environ["INFRAI_API_KEY"]


def call(method, path, payload=None, idempotency_key=None):
    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
    }
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    for attempt in range(4):
        response = requests.request(
            method=method,
            url=f"{BASE_URL}{path}",
            headers=headers,
            json=payload,
            timeout=10,
        )
        if response.status_code != 429:
            if not response.ok:
                raise RuntimeError(f"HTTP {response.status_code}: {response.text}")
            return response.json()
        retry_after = response.headers.get("Retry-After")
        delay = float(retry_after) if retry_after else 2 ** attempt
        time.sleep(delay)
    raise RuntimeError("rate limit persisted after retries")


pending = json.loads(os.environ["EMAIL_CHANGE_REQUEST_JSON"])
request_result = call(
    "POST",
    "/auth/email/change_request",
    pending,
    idempotency_key=str(uuid.uuid4()),
)

confirmation = json.loads(os.environ["EMAIL_CHANGE_CONFIRM_JSON"])
confirmed = call(
    "POST",
    "/auth/email/change_confirm",
    confirmation,
    idempotency_key=str(uuid.uuid4()),
)

user_id = os.environ["USER_ID"]
current_user = call("GET", f"/auth/user/get/{user_id}")
print({"request": request_result, "confirmed": confirmed, "user": current_user})
Enter fullscreen mode Exit fullscreen mode

The code does not print the submitted payloads, which matters because application logs are often longer-lived than the verification transaction. Return a generic message such as “If the account can use that address, we sent instructions.” That keeps enumeration resistance separate from the transport’s actual status handling.

Which migration path preserves the account graph during an email change?

Moving off a managed provider is less about swapping an endpoint than preserving identifiers, linked providers, and recovery semantics. Export formats differ, and a provider’s social-identity key may not equal your internal user id. Test a migration with a fixture containing one password account, one Google identity, and one GitHub identity before touching production data.

Option Continuity work during migration Workflow control Where it fits
Auth0 Map user_id and connection identities; verify export/import limits Hosted rules and actions, with provider-specific behavior Teams wanting a mature hosted identity surface
Firebase Authentication Reconcile Firebase UIDs with your game’s user table and provider links Strong client SDK integration; server workflow remains yours Games already centered on Firebase services
Amazon Cognito Plan pool-to-pool identifiers and federation mappings Deep AWS integration, with more AWS-shaped configuration AWS-heavy operations teams
A plain REST auth layer Own the user-id map, code lifecycle, throttling, and audit trail Maximum control; you operate the policy and delivery pieces Teams leaving a managed provider for portable backend calls

Infrai uses one key for this plain HTTP integration, and its discovery API is self-describing: an engineer can inspect a capability’s schema and runnable examples instead of learning another SDK before wiring the two transitions. A consistent REST convention can also keep auth beside the rest of a backend during a staged migration. The same credential can cover several backend capabilities, which removes a surprisingly mundane migration task: rotating separate keys and reconciling separate invoices while the identity map is still being validated. That is an integration property, not proof that the service should own every identity decision.

The catch is operational ownership. A team that needs a provider-managed admin console, built-in tenant isolation, or a turnkey social-identity import may be better served by Auth0 or Cognito, even if that means accepting their hosted workflow model. Infrai is not suitable when your compliance process requires those provider-specific controls; stick with the managed option until that requirement changes. Its one key and one bill model can reduce credential and invoice sprawl across a staged backend migration, but it does not remove the need to design your own identity policy. I’m not sure any comparison table can settle that question without your threat model and export constraints.

The rejected shortcut, and when it is still valid

I would reject “change the email immediately, then send a code” for this workflow. It creates a recovery gap: a mistyped destination can strand the player, and a later Google or GitHub login may be matched against the wrong address. I would also reject putting the code in a URL query string or an exception message; those surfaces leak into access logs, analytics, and support tickets.

The shortcut is valid only for a low-risk profile field that is not used for sign-in or recovery, and even then it should be clearly separated from the canonical email. For an account migration, preserve the old address until confirmation, record who or what initiated the request, and make support able to revoke a pending change without exposing the secret itself.

One practical detail is easy to miss. A player can open the change screen on a phone, request a code, then finish on a desktop after the original session has expired. The pending record must therefore carry enough server-side context to bind confirmation to the intended user and destination, while the client remains free to restart the flow without learning whether an address is already registered. That separation is what lets a support agent invalidate one pending change without touching the Google or GitHub links, and it is why “just update the row” is the wrong abstraction even for a small game.

A release checklist for the state boundary

Before rollout, exercise the unhappy paths: duplicate requests, expired codes, too many attempts, replayed confirmations, and a destination already associated with another user. Assert that each path returns a non-sensitive message, emits an audit event without the code, and leaves the Google/GitHub identity links on the original user. Then run a migration rehearsal and compare user ids before and after.

The implementation is ready when the confirmation transition is the only write that changes the canonical email, and when operators can explain every pending or completed change from durable audit data. That is a narrower claim than “account security solved,” but it is a boundary you can actually test.

References

Top comments (0)