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),
)
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.
Top comments (0)