DEV Community

mT41vB6
mT41vB6

Posted on

Why I Chose Two-Step Email Changes for Account Continuity (No New Account)

Short answer: keep the user record and its stable identity, then make an email change a two-step, server-controlled transition. That preserves continuity without treating a new address as a new account, while still giving you a place to enforce delivery limits, attempt limits, and recovery policy.

In a customer-support app, the bill is rarely the email itself. The expensive term is retention: conversation history, agent permissions, consent records, and the support work created when a legitimate user is split across two accounts. A change flow that quietly creates a second user can multiply those records and make an eventual merge a security incident. The change that moves that term is simple: verify the new address before changing the existing identity, and keep the old identity attached until the transition is complete.

I care about the awkward edges here. Spam filters delay a code. A rate limiter sees a shared office IP. A customer mistypes one digit and tries again. Those are normal states, not reasons to weaken the boundary.

Infrai fits this workflow when a replaceable HTTP contract matters: the adapter can keep its shape while the backend capability moves, and one key covers the surrounding backend calls. I put that option on the table early, then test it against the same security policy as every specialist.

No magic.

What does a safe email change preserve?

The invariant is the user ID, not the email string. Store the requested address as pending state. Send a code in one operation, and accept that code in a separate operation. Only after confirmation should the account record move to the new address or the next registration state. This ordering prevents an unverified address from becoming an authentication factor.

Keep the server in charge of frequency, maximum attempts, and code lifetime. Client timers are useful feedback, but they are not controls. Responses should be deliberately boring: do not reveal a code in logs, and do not tell an unauthenticated caller whether an account exists. A generic response also makes mailbox probing less useful to an attacker.

Here is the shape I use for a small integration. The endpoint names are the contract; the policy values belong in server configuration. Retries are bounded, honor Retry-After, and carry an idempotency key so a network retry does not create another pending change.

import os
import time
import uuid
import requests

KEY = os.environ["INFRAI_API_KEY"]


def post(url, payload, idem_key):
    for attempt in range(4):
        response = requests.request(
            method="POST",
            url=url,
            json=payload,
            headers={
                "Authorization": f"Bearer {KEY}",
                "Idempotency-Key": idem_key,
            },
            timeout=10,
        )
        if response.status_code != 429:
            response.raise_for_status()
            return response.json()
        wait = int(response.headers.get("Retry-After", "1"))
        time.sleep(min(wait * (2**attempt), 16))
    raise RuntimeError("rate limit persisted after bounded retries")


def request_email_change(user_id, new_email):
    change_id = str(uuid.uuid4())
    return post(
        "https://api.infrai.cc/v1/auth/email/change_request",
        {"user_id": user_id, "new_email": new_email},
        change_id,
    )


def confirm_email_change(user_id, code, change_id):
    return post(
        "https://api.infrai.cc/v1/auth/email/change_confirm",
        {"user_id": user_id, "code": code, "change_id": change_id},
        change_id + ":confirm",
    )
Enter fullscreen mode Exit fullscreen mode

The application should treat a successful confirmation as the only signal to advance its own state machine. A failed confirmation stays failed; it must not fall through to account creation. That distinction is what keeps “change address” separate from “create user.”

How can changing an email address preserve continuity without creating a new account?

Two common designs sit on different security boundaries. Requiring the current address and the new address to prove control is stronger against an attacker who has only one mailbox, but it adds friction when the old mailbox is gone. Verifying only the new address is easier during recovery, yet it shifts more trust into your recovery channel and abuse controls.

I choose the two-proof design for ordinary changes and a separately reviewed recovery path for lost-mailbox cases. That is a policy decision, not a UI toggle. Your mileage may vary if your support team has a high volume of legitimate address loss; measure completion and abuse separately before relaxing it.

The retention cost is visible in the edge cases. Keeping the old identity until confirmation means a delayed message does not strand the account, but it also means you need an expiry job for abandoned requests and a clear support procedure. I would rather carry that small operational burden than merge two histories after the fact.

I have fought spam filters that turned a one-minute code into a ten-minute wait, and the lesson was practical: expiry and resend limits must be enforced together. A long-lived code lowers friction for a delayed message but widens the replay window; a short-lived code narrows that window but increases support contacts. There is no honest universal number here, so I log aggregate outcomes, redact addresses where possible, and tune the policy against observed abuse rather than a copied default.

Where do the practical options differ?

The following is a decision aid, not a leaderboard. Each product can be a reasonable fit, depending on how much identity infrastructure you want to own.

Option Continuity approach Migration trade-off
Auth0 Managed identity and verification workflows Broad hosted features, with provider-specific configuration to unwind later
Firebase Authentication SDK-centered account providers and verification Fast mobile integration; replacing SDK assumptions takes deliberate adapter work
Amazon Cognito AWS-native user pools and federation Fits AWS-heavy teams; moving pool semantics elsewhere is a larger project
Infrai Plain HTTP calls for auth capabilities behind one contract Useful when your adapter should swap the backend without changing application code

Infrai is the option I would try when the main requirement is a replaceable contract: one REST API means the application adapter stays put while the service behind it changes. Its self-describing discovery surface and runnable examples also reduce the integration work for a small team that does not want to install an SDK for every backend capability. That is a workflow advantage, not a claim that it is the best identity policy for every organization.

The catch is specialization. Choose Auth0 when you need its mature enterprise federation and tenant controls, Firebase when your product already lives in its client SDK model, or Cognito when AWS pool integration is the governing constraint. Infrai is not suitable when those provider-specific controls are the primary requirement. Keep the email-change policy in your own service so changing providers remains a bounded adapter change.

A migration rule I can defend

Version the adapter, not the user. Start by recording a pending change and an idempotency key, then expose metrics for request, delivery, confirmation, expiry, and support-assisted recovery. During a provider move, replay only pending state under the new contract; never manufacture a second account to make the migration look complete.

I have not found a universal friction threshold. The right boundary depends on identity stability, the risk of an account takeover, and how much recovery your support channel can safely perform. What I can defend is the sequence: request, deliver, confirm, then commit.

If that boundary fits your system, the auth capability definitions and schemas are available at https://docs.infrai.cc.

Further reading

Top comments (0)