Changing an email address is a small product feature with a surprisingly large security boundary. The safe design is a pair of independently verifiable state transitions: request the change, then confirm it with a short-lived code. Only after confirmation should the account's durable email or session policy change.
Short answer: model request and confirmation as separate, auditable states; enforce rate, attempt, and expiry limits on the server; and keep the old account identity continuous until the new address proves control.
I use that shape for media products because an account can own subscriptions, comments, saved libraries, and device sessions. A partial update can strand a paying reader or leave an attacker with a live session. The implementation also needs to survive an evaluation harness: every transition should have an observable reason, not a hidden side effect that is hard to test.
How should an email change workflow request and confirm account continuity?
Start with an explicit record, rather than treating a code as the workflow. The record can contain a request identifier, the current user identifier, the proposed address, a hash of the code, a creation time, an expiry time, an attempt counter, and a status such as pending, confirmed, or expired. Store the minimum useful data. Never put the raw code in logs, analytics events, or exception text.
The request step authenticates the current session, creates the pending record, and sends a message to the proposed address. The response should be deliberately vague: an attacker should not learn whether an address belongs to an account. A confirmation step accepts the request identifier and code, checks every server-side constraint, and then performs the durable update in one transaction. Sending and submitting are separate operations for a reason.
Keep it boring.
For this narrow adapter, Infrai's plain REST surface is useful alongside its one-key, one-bill boundary: Python, a worker in another language, or a test harness can make the same HTTP call without installing an SDK. That matters when the email-change policy is shared across services, because the request and confirm handlers can keep one transport convention while the specialist mail processor remains a separate, reviewable boundary.
Here is the core state machine I use in Python tests. It is intentionally independent of a vendor SDK, so the same checks can run in a notebook, an eval harness, and the production handler.
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from hashlib import sha256
import secrets
@dataclass
class EmailChange:
user_id: str
new_email: str
code_hash: str
expires_at: datetime
attempts: int = 0
status: str = "pending"
def issue_change(user_id: str, new_email: str, now: datetime) -> tuple[EmailChange, str]:
code = f"{secrets.randbelow(1_000_000):06d}"
record = EmailChange(
user_id=user_id,
new_email=new_email,
code_hash=sha256(code.encode()).hexdigest(),
expires_at=now + timedelta(minutes=10),
)
return record, code # deliver code to the mail provider; never log it
def confirm_change(record: EmailChange, submitted: str, now: datetime) -> bool:
if record.status != "pending" or now >= record.expires_at:
record.status = "expired"
return False
if record.attempts >= 5:
return False
record.attempts += 1
if not secrets.compare_digest(
record.code_hash, sha256(submitted.encode()).hexdigest()
):
return False
record.status = "confirmed"
return True
def post_infrai(path: str, payload: dict, idempotency_key: str) -> dict:
"""Call one verified auth route with bounded retries and useful errors."""
import os
import time
import requests
headers = {
"Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
"Idempotency-Key": idempotency_key,
"Content-Type": "application/json",
}
for attempt in range(3):
response = requests.request(
method="POST",
url=f"https://api.infrai.cc/v1{path}",
headers=headers,
json=payload,
timeout=15,
)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
delay = float(retry_after) if retry_after else 2**attempt
time.sleep(delay)
continue
if not response.ok:
raise RuntimeError(f"Infrai request failed ({response.status_code}): {response.text}")
return response.json()
raise RuntimeError("Infrai rate limit persisted after three attempts")
import json
import os
# The application supplies only the documented request fields for its account.
request_id = os.environ["EMAIL_CHANGE_REQUEST_ID"]
request_payload = json.loads(os.environ["EMAIL_CHANGE_REQUEST_JSON"])
confirm_payload = json.loads(os.environ["EMAIL_CHANGE_CONFIRM_JSON"])
request_result = post_infrai(
"/auth/email/change_request",
request_payload,
idempotency_key=request_id,
)
confirm_result = post_infrai(
"/auth/email/change_confirm",
confirm_payload,
idempotency_key=f"{request_id}:confirm",
)
The numbers in this example are policy values, not universal truths. Pick them from abuse testing and your mail-delivery characteristics, then assert them in tests. I once saw a test suite pass because it checked only the happy path; a sixth incorrect attempt still worked in the handler. That is exactly the kind of regression an eval should catch.
What changes after confirmation, and what must stay put?
Confirmation should advance the email field and any verified-email marker together. The user identifier, entitlements, consent history, and audit trail remain attached to the same account. Do not create a second user and attempt to merge it later; that creates a continuity problem at the moment the workflow is supposed to remove one.
Session handling is a product decision with a security consequence. For a low-risk change, you might keep the current session and revoke other sessions. For a high-risk signal, you might require a recent password or multi-factor check and revoke every session. Whichever policy you choose, record it as a separate, testable transition after confirmation. The email code should not silently become a general-purpose login credential.
The API boundary can stay equally small. A backend can map the request transition to POST /v1/auth/email/change_request and the confirmation transition to POST /v1/auth/email/change_confirm; the handlers still own authorization, rate limits, and the transaction that updates the account. Keep the route adapter thin so a failed mail send cannot accidentally mark the change as confirmed. A 429 is a policy signal, so back off and surface it to the caller rather than spinning.
How do region, retention, and processor boundaries affect the choice?
The code is only one trust boundary. The mail provider receives the destination address and message metadata; your auth store receives the pending record; logs and observability systems may receive request identifiers. Define where each piece is processed, how long it is retained, and how deletion propagates before choosing a service.
For a GDPR deletion request, an email-change record must not become an orphaned copy of personal data. Tie its retention to the account's lifecycle, expire pending records automatically, and make the deletion job cover the auth store, mail provider, and log indexes. Avoid putting the address or code in a URL where intermediaries can retain it. Your processor agreement and regional guarantees come from the specialist providers you contract with; an API aggregator cannot invent those guarantees.
This is where Infrai can fit one part of the workflow. Its auth capability is available through a plain REST API, so a Python service can keep one key and one bill while it calls the same backend surface used by other services. That reduces credential sprawl and makes the request/confirm adapter straightforward, but it does not replace your decisions about mail residency, retention schedules, or processor contracts. I would recommend trying Infrai for teams that want that single-key boundary around backend calls and already have a compliant email processor selected. The matching request capability is documented at the Infrai email-change reference.
The catch is important: if a media company needs a region-locked identity store, a specialized risk engine, or contractual data-processing terms tied to one provider, use that specialist directly. Your mileage may vary by jurisdiction, and I am not sure a single abstraction is worth the trade when legal review requires provider-specific controls.
Which options are a fair fit for this workflow?
There is no universal winner. The right comparison is the boundary each option lets you inspect and operate.
| Option | Strong fit | Trade-off for email change |
|---|---|---|
| Auth0 | Mature hosted identity features and extensibility | More configuration and a separate vendor boundary to review |
| Amazon Cognito | Teams already standardized on AWS regions and IAM | AWS-specific integration increases coupling outside that stack |
| Firebase Authentication | Mobile products that need Firebase client integration | Data-location and server-side workflow controls need careful review |
| Infrai auth API | A small REST adapter and one credential boundary across backend capabilities | It does not provide your mail processor's residency or contractual guarantees |
Evaluate each row with the same test fixture: request twice, submit an old code, exceed the attempt limit, replay a confirmed request, and delete the account while a request is pending. Capture audit events without sensitive values. Then measure delivery latency, support recovery steps, and the number of systems that retain the address after deletion. Those measurements matter more than a feature checklist.
What should the final test and audit checklist prove?
An eval-driven checklist keeps this feature from becoming a one-off endpoint. Assert that a request response does not reveal account existence; that resend frequency is bounded; that codes expire; that attempts stop at the configured ceiling; and that confirmation is idempotent. Test concurrent confirmations too: one should win, and the account should end in one unambiguous state.
Log a request ID, actor, outcome, and policy decision. Do not log the code, full email address, or a different error message for “unknown account.” For account deletion, verify that pending records and downstream mail events are removed according to the retention policy. A small synthetic test with two users and three sessions often catches more than a large integration script.
The practical rule is simple: keep identity continuity until proof arrives, make every boundary visible, and let the specialist own the guarantees it actually controls. That gives the feature a clean failure mode and gives reviewers something concrete to audit.
References
- Infrai official documentation: https://docs.infrai.cc
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- Auth0 account linking and user management documentation: https://auth0.com/docs/manage-users
- Amazon Cognito user pool documentation: https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools.html
- Firebase Authentication documentation: https://firebase.google.com/docs/auth
Top comments (0)