DEV Community

ColeMitchell4991
ColeMitchell4991

Posted on

Five Checks for E-commerce Phone Number Swaps Before Identity State Changes

Short answer: treat a phone-number change as a pending identity transition, verify the new channel with a fresh challenge, and only then commit account state. For an e-commerce app that scores login risk from device fingerprints, this ordering prevents a stolen session from turning a new number into the recovery path.

I build RAG and agent features in Python, so I tend to start in a notebook with a tiny event table and an eval harness. That habit exposed an uncomfortable assumption in a phone migration flow: the code trusted the request that said the new number had been verified, while the verification event was still in flight. The fix was a state machine, not a larger model.

What should happen before a phone number migration changes account state?

A safe flow has two independent proofs. First, establish that the person can still authenticate to the account using the current factor or a recovery method with an equivalent assurance level. Second, send a one-time challenge to the new number and bind the resulting confirmation to the exact account, device context, and change request. A successful challenge creates an expiring pending_phone record; it does not overwrite phone yet.

That distinction matters during a mobile number swap. The old channel can stop receiving messages while the app is backgrounded, the user can retry from a different device, and a replayed confirmation can arrive after a newer request. I use a monotonically increasing request identifier and an expiry window so a late event cannot win a race against a newer transition.

It failed once in a test.

The test used two mobile clients against the same account. Client A created request 7 and received a challenge; before its confirmation arrived, client B created request 8. A naive handler accepted A's delayed message and wrote the new number, then B's pending record disappeared. The corrected handler compared the request id inside the transaction, retained request 8, and recorded A as stale. That single fixture now runs in the notebook eval, the service test suite, and the deployment smoke test, which is useful because a model-generated refactor can preserve the happy path while quietly removing the concurrency guard.

The account remains in its previous recovery state until the commit step checks all of these values again: request id, challenge nonce, account id, and risk decision. This is deliberately boring. Boring state transitions are easier to audit.

A small Python state machine for pending channels

The following sketch keeps transport details outside the decision logic. It is the sort of code I can move from a notebook into a service after tests cover the transitions.

from dataclasses import dataclass
from datetime import datetime, timedelta, timezone

@dataclass
class PhoneChange:
    account_id: str
    request_id: int
    pending_phone: str
    challenge_nonce: str
    expires_at: datetime
    verified: bool = False

def verify_new_channel(change: PhoneChange, account_id: str,
                       request_id: int, nonce: str, now: datetime) -> bool:
    if now >= change.expires_at:
        return False
    if (change.account_id, change.request_id, change.challenge_nonce) != (
        account_id, request_id, nonce
    ):
        return False
    change.verified = True
    return True

def commit_phone(change: PhoneChange, current_request_id: int, now: datetime) -> str:
    if not change.verified or now >= change.expires_at:
        raise ValueError("phone change is not ready to commit")
    if change.request_id != current_request_id:
        raise ValueError("a newer phone change is pending")
    return change.pending_phone

change = PhoneChange(
    account_id="acct-42",
    request_id=7,
    pending_phone="+15550109",
    challenge_nonce="n-8f2",
    expires_at=datetime.now(timezone.utc) + timedelta(minutes=10),
)
Enter fullscreen mode Exit fullscreen mode

In production, the write that marks the change committed should be conditional on the request id. A database transaction or compare-and-swap guard gives the same property: exactly one current transition can publish the new number. The SMS or voice provider is only a channel; it should not be allowed to mutate the account directly.

How do risk scores, device fingerprints, and a new channel fit together?

A device fingerprint is a signal, not proof of possession. For login-risk scoring, I feed it into a decision that can require step-up verification, but I never let a familiar fingerprint skip the new-channel challenge. Fingerprints can be reset by app reinstall, shared by households, or distorted by privacy controls.

The useful event sequence is: authenticate the existing account, create a pending change, send a challenge, verify the challenge, recompute risk, then commit. Each event should carry a correlation id and an outcome such as accepted, expired, or rejected; logging the raw code or full phone number would create a different security problem.

Here is a compact evaluation table I use before shipping an agent-assisted implementation:

Check Failure it catches Evidence to retain
Replay test Old confirmation changes a newer request Request id and nonce match
Race test Two devices both commit Conditional write result
Expiry test Delayed message remains valid Server-side expiry decision
Risk test High-risk session changes recovery factor Step-up decision and reason

I measure false accepts and false rejects separately. Token cost matters too: a prompt that summarizes every login event is expensive and adds little signal, so my eval harness passes a fixed, redacted feature set to the model and keeps the final commit rule deterministic. I'm not sure a model belongs in the final authorization decision at all; your mileage may vary, but the state transition should stay testable without one.

The catch is that a phone number is not a universal high-assurance factor. If the account protects high-value payouts, an attacker who controls telecom routing may still defeat an SMS challenge. Use a phishing-resistant factor, a recovery code, or manual review when the risk policy demands stronger proof.

This pattern is also a poor fit for an offline-first flow that must complete without a reachable verification service. In that case, keep the old number active until the device reconnects, and make the pending state visible to support staff. Stick with a simpler profile update when the number is only a shipping contact and has no role in authentication or recovery.

References

Top comments (0)