DEV Community

HoldenFox8476
HoldenFox8476

Posted on

Gaming Account Phone Swaps — Six Proofs Before Identity State Changes

Short answer: treat a phone-number migration as a new authentication ceremony, and commit the account change only after the new channel proves control under an abuse budget.

That sounds obvious until a game launch turns the endpoint into a bot target. A player can still be signed in on an old device, an SMS can arrive late, and a recycled number can belong to somebody else. The migration flow has to preserve the old recovery path while it tests the new one. I build it as a small state machine, not as an update statement with a code check bolted on.

How should a gaming app verify a new phone channel before account state changes?

The invariant is simple: no successful code entry means no durable change to phone_number, phone_verified_at, or the factor used for recovery. The temporary transaction can be disposable; the account record cannot be half-migrated.

That is the boundary.

No shortcut.

I use six proofs around that invariant:

  1. Intent: an authenticated session asks to replace the number, with a recent re-authentication for risky accounts.
  2. Possession: generate a single-use code and send it to the proposed number.
  3. Binding: store a hash of the code, the normalized E.164 number, transaction ID, and an expiry; never log the code itself.
  4. Abuse budget: rate-limit by account, destination, device, IP range, and challenge transaction. A CAPTCHA or step-up challenge belongs here when signals get ugly.
  5. Freshness: accept the code once, before its short expiry, and invalidate every sibling challenge after success.
  6. Commit: write the new number and audit event in one database transaction, then notify the old channel.

The notification is part of the security boundary. It gives the legitimate owner a chance to freeze the account if a stolen session initiated the swap. OWASP recommends reauthentication and careful handling of account recovery signals; a phone swap should meet that bar, not quietly lower it. The linked cheat sheet is listed in References.

Failure boundaries belong in the same decision record.

Write down what each failure means before implementation. A timeout means “still pending,” not “the number is invalid.” A wrong code consumes an attempt, while an expired transaction consumes the whole challenge. A delivery provider timeout is an operational event; it must not turn into a successful migration or a mysterious 500 shown to the player.

Codes expire.

Here is the option comparison I keep in the architecture record. The names are examples of integration shapes, not endorsements.

Option Useful boundary Cost or risk
Direct SMS API (for example, Twilio Verify) Fast delivery and managed code lifecycle Provider policy, sender registration, and regional deliverability remain external dependencies
Cloud messaging primitive (for example, Amazon SNS) Flexible routing and existing cloud identity controls Your service owns code storage, replay defense, and more compliance work
Self-hosted gateway Maximum control over data and routing Carrier contracts, filtering, and on-call burden become your problem

The rejected option is “update first, verify later.” It creates a recovery race: the attacker who controls a session can replace the factor, request a reset, and erase the owner’s best signal. That design is valid only for a non-security contact field, such as an optional marketing number that cannot authenticate or recover an account.

A critical path that keeps state boring

The endpoint names in this example are intentionally generic. The important part is the ordering and the idempotency key, not a vendor SDK.

from datetime import datetime, timedelta, timezone
import hashlib
import hmac
import secrets

CODE_TTL = timedelta(minutes=5)
MAX_ATTEMPTS = 5

def begin_phone_swap(account_id: str, new_number: str, request_id: str, store, sms):
    normalized = normalize_e164(new_number)
    tx = store.create_swap(
        account_id=account_id,
        number=normalized,
        request_id=request_id,
        code_hash=None,
        expires_at=datetime.now(timezone.utc) + CODE_TTL,
        attempts=0,
        status="pending",
    )
    code = f"{secrets.randbelow(1_000_000):06d}"
    store.set_code_hash(tx.id, hashlib.sha256(code.encode()).hexdigest())
    sms.send(normalized, f"Your game login code is {code}")
    return tx.id

def confirm_phone_swap(account_id: str, tx_id: str, supplied_code: str, store):
    tx = store.lock_swap(tx_id, account_id)
    now = datetime.now(timezone.utc)
    if tx.status != "pending" or now >= tx.expires_at:
        raise ValueError("challenge_not_active")
    if tx.attempts >= MAX_ATTEMPTS:
        raise ValueError("attempt_budget_exhausted")
    supplied_hash = hashlib.sha256(supplied_code.encode()).hexdigest()
    if not hmac.compare_digest(supplied_hash, tx.code_hash):
        store.increment_attempts(tx.id)
        raise ValueError("code_rejected")
    with store.transaction():
        store.mark_used(tx.id)
        store.replace_login_number(account_id, tx.number)
        store.append_audit(account_id, "phone_factor_replaced", tx.id)
    return {"status": "committed"}
Enter fullscreen mode Exit fullscreen mode

The lock prevents two confirmations from both passing the one-use test. In production I also make request_id unique, queue the old-channel notification after commit, and keep audit records append-only. Never put the destination number, code, or raw device fingerprint into ordinary application logs; retain only the minimum data your retention policy allows.

What does bot resistance change in the migration workflow?

Gaming abuse is usually asymmetric: sending thousands of codes is cheap for a botnet and expensive for your sender reputation. Put a quota in front of the SMS call, and make the quota decision explainable to support staff. A per-account limit alone fails when one attacker creates many accounts; a per-IP limit alone punishes a dorm or a mobile carrier NAT.

Use a layered key such as (account, destination prefix, device risk, IP /24, hour), then add a global sender ceiling. Return the same user-facing response for “number exists,” “number blocked,” and “message queued” so the endpoint does not become an account-enumeration oracle. I have seen delivery gaps caused by carrier filtering, so the retry path should offer a fresh transaction with a backoff, never resend the same code forever.

Measure initiation, delivery acknowledgement, verification success, expiry, and old-channel cancellation separately. A spike in initiation with flat verification is an abuse signal; a spike in delivery latency is a routing signal. Your mileage may vary by country, sender type, and carrier, so keep those dimensions in the dashboard rather than hiding them in one success percentage.

Keep the raw events.

When is this pattern the wrong fit?

SMS is not a universal proof of identity. It is vulnerable to SIM-swap and number recycling, and some players cannot receive short codes while roaming. For high-value inventories or tournament payouts, stick with a phishing-resistant authenticator or passkey as the recovery anchor, and use the phone only as a notification or secondary signal.

The catch is operational: this flow needs sender registration, regional policy review, and a support procedure for locked-out owners. If your game has no staffed recovery channel, a phone swap may be unsuitable; keep the existing factor until a stronger recovery method is enrolled. I'm not sure any single delivery metric can capture that risk, which is why the decision record should name the unacceptable failure, the owner, and the rollback action.

References

Top comments (0)