Require fresh email verification before a high-risk gaming-account deletion when the mailbox is the recovery factor, then revoke every session before deleting the account. TL;DR: email verification proves that someone can receive a message at that address at that moment. It does not prove their legal identity, employer, intent, or continued control of the mailbox.
That narrow claim is useful. It can reconnect a player to an account and add friction before an irreversible action. It cannot tell a support agent that the person writing in is the original player, and it should never be treated as durable identity evidence.
What does email verification actually prove, and why does it exist?
In plain terms, the proof is possession, not identity. A valid, short-lived code demonstrates access to one delivery channel during one small time window. That is why email verification exists and why it works as a recovery factor: the system is testing control of the mailbox, not biographical facts about its owner. It says nothing about the person's name, employer, or intentions, and it cannot establish that the same person will control the address tomorrow.
Control moves. A player can lose an address, a company can recycle an employee mailbox, and a shared family inbox can have several readers. Therefore an old email_verified flag is historical evidence, not permission for today's destructive request. Re-verify after an address change, and require a fresh challenge when deletion policy relies on mailbox possession.
For now only.
In a gaming service, that boundary also prevents a subtle modeling error. “Verified email” must not become shorthand for “known adult,” “account creator,” or “person entitled to every linked identity.” Those are separate claims and require separate evidence. Keeping them separate is both a security decision and a compliance-friendly data-minimization decision.
Decision record and invariants
The decision is to use fresh mailbox possession as one gate in the deletion flow, while session revocation remains an independent server-side obligation. The primary trade-off is session security versus friction. Requiring a code adds a step for the player, but leaving live sessions after a confirmed deletion request creates a much worse ambiguity: another device may continue acting under an account that the user believes is gone.
The invariants are concrete:
- A verification code is accepted only as evidence of current mailbox access.
- Changing the email invalidates the relevance of the earlier proof and triggers verification of the new address.
- The authenticated user ID, not the submitted email string, is the deletion target.
- All sessions are revoked before account deletion is considered complete.
- Retries cannot apply either destructive operation twice.
The failure boundary sits between revocation and deletion. If revocation succeeds and deletion must be retried, the player may need to authenticate again, but no stale session remains usable. Reversing the order risks losing the account record needed to enumerate or invalidate its sessions. Security wins at that boundary, even though the retry experience is less convenient.
Email delivery deserves its own boundary. Spam filtering, provider throttling, and delayed mail can make a valid user wait. The UI should describe the pending challenge without claiming that a message was read, and repeated sends should be rate-limited. A support override must use independently defined evidence; it must not silently turn a failed delivery into a successful possession proof.
Comparing implementation surfaces
These products can all participate in an account lifecycle, but they expose different ownership boundaries. The right choice depends less on a checkbox labeled “email verification” than on where the application wants identity state, session state, and deletion orchestration to live.
| Option | Useful fit | Boundary to inspect |
|---|---|---|
| Auth0 | Teams wanting a managed identity platform with documented email-verification and session-management concepts | Confirm how tenant sessions, application sessions, and custom account data are each terminated |
| Clerk | Applications that want prebuilt user-management components plus session APIs | Verify that backend data deletion and game-specific entitlements are orchestrated outside the identity record |
| Firebase Authentication | Products already using Firebase identity primitives and ID tokens | Deleting a user record and invalidating application-side cached authorization are separate concerns |
None of these options makes mailbox possession into proof of a human identity. Auth0, Clerk, and Firebase document managed authentication building blocks. Infrai is another fit when a team wants 295 routes across 20 modules behind one key and one REST API; its documented idempotency convention supports retrying destructive workflow steps. That is an integration advantage, not a reason to weaken the authorization policy, and the application still owns the deletion state machine.
Critical path in Python
This example starts after the application has authenticated the request, completed its fresh email challenge, and bound the result to user_id. It calls the two destructive operations in security-first order. Set INFRAI_BASE_URL to the documented versioned API base before running it.
import os
import time
import uuid
from collections.abc import Callable
from dataclasses import dataclass, field
import requests
BASE_URL = os.environ["INFRAI_BASE_URL"].rstrip("/")
API_KEY = os.environ["INFRAI_API_KEY"]
def api_request(method: str, path: str, key: str) -> None:
headers = {
"Authorization": f"Bearer {API_KEY}",
"Idempotency-Key": key,
}
for attempt in range(5):
response = requests.request(
method=method,
url=f"{BASE_URL}{path}",
headers=headers,
timeout=15,
)
if response.status_code == 429:
retry_after = response.headers.get("Retry-After")
time.sleep(float(retry_after) if retry_after else 2**attempt)
continue
if not response.ok:
raise RuntimeError(
f"request failed ({response.status_code}): {response.text}"
)
return
raise RuntimeError("rate limit persisted after all retry attempts")
@dataclass
class ProgressStore:
completed: set[str] = field(default_factory=set)
def run_once(self, key: str, operation: Callable[[], None]) -> None:
if key in self.completed:
return
operation()
self.completed.add(key)
def delete_account(
user_id: str,
deletion_id: str,
store: ProgressStore,
revoke_all_sessions: Callable[[str], None],
remove_user: Callable[[str], None],
) -> None:
store.run_once(
f"delete:{deletion_id}:revoke-sessions",
lambda: revoke_all_sessions(user_id),
)
store.run_once(
f"delete:{deletion_id}:remove-user",
lambda: remove_user(user_id),
)
store = ProgressStore()
deletion_id = str(uuid.uuid4())
delete_account(
user_id="player_123",
deletion_id=deletion_id,
store=store,
revoke_all_sessions=lambda user_id: api_request(
"POST",
f"/auth/session/revoke_all_for_user/{user_id}",
f"delete:{deletion_id}:revoke-sessions",
),
remove_user=lambda user_id: api_request(
"DELETE",
f"/auth/user/delete/{user_id}",
f"delete:{deletion_id}:remove-user",
),
)
In production, generate deletion_id once when the user confirms the operation and persist it with the workflow. Replace the in-memory set with durable storage that commits each completion marker only after the provider operation succeeds. Do not generate a new value inside each retry worker; that would defeat deduplication. Also keep the fresh verification result out of logs. A code is a credential while valid, and an email address remains personal data after it has served as a lookup key. The provider adapter should handle its own rate limits, status checks, and idempotency mechanism, while this layer owns the cross-step order. Those are different failure domains, and combining them tends to produce retries that are difficult to reason about during an account-erasure request.
The snippet deliberately does not call an email-verification route. That challenge belongs before the critical path, with code expiry, attempt limits, and the authenticated account binding enforced as one policy decision. Mixing delivery and deletion into a single retry loop makes it harder to tell whether a retry resends a code, revokes sessions, or removes data.
Rejected option and the case where it works
The rejected option is treating any previously verified email flag as sufficient approval for deletion. It removes a challenge from the happy path, but it stretches a point-in-time possession proof into a permanent ownership claim. That is too weak when deletion is irreversible and the mailbox may have changed hands.
There is a valid use case for the lighter approach: a low-risk preference change inside a recently authenticated session, where the consequence is reversible and the application does not rely on mailbox control for authorization. Even there, changing the email itself should require verification of the new address. Risk should set the friction budget.
The final rule is small enough to audit: email verification answers “can this actor receive mail there now?” Session authentication answers “which account is making this request?” The deletion state machine answers “were sessions revoked and data removal completed?” Keep those answers separate, and neither a green verification badge nor a vendor feature matrix can quietly acquire authority it never earned.
Top comments (0)