Short answer: treat an email change as a two-phase identity handoff, with a pending address, a single-use confirmation token, and the old login preserved until the new address is proven; this keeps a marketplace account usable while the change is in flight and gives reconciliation jobs an auditable state transition.
The migration decision is therefore about invariants, not about which managed authentication provider has the nicest settings page. The application already has customers, sellers, sessions, payout records, and support workflows keyed to one account ID. Changing the email must never create a second account, silently move ownership of a seller profile, or leave a token that can be replayed. I would make the database the source of truth, put delivery behind an adapter, and make every state change idempotent.
This is an architecture decision record for a marketplace adding phone one-time-code login while moving away from a managed provider. The phone code is an authentication factor; it is not proof that a newly requested email belongs to the same person. That distinction is the boundary that prevents a convincing but dangerous shortcut.
What should a marketplace request and confirm before preserving account continuity?
Start with a small state machine. The account keeps its current_email; an email_change_request stores the normalized proposed_email, a hash of a random token, an expiry, and the account ID. The request is pending until the new address confirms. A successful confirmation atomically marks the request used, updates the account, and records an audit event. Repeating the same confirmation returns the already-completed result without applying a second update.
The old address should receive a notification, not a second approval step, unless the threat model requires it. OWASP recommends generic authentication responses and careful handling of account recovery; the same spirit applies here, because an endpoint that says “that email is already registered” becomes an account-enumeration oracle. Rate-limit requests and confirmation attempts by account, address, and network signal, and put a support-reviewed recovery path around suspicious changes.
The continuity invariant is simple: sessions, marketplace roles, ledger ownership, and payout identity refer to the immutable account ID, never to an email string. A phone one-time code can authenticate a returning account, but it must resolve to that ID before any email mutation is authorized.
A short sentence belongs in the runbook: no account split.
Keep it transactional.
The decision record: which boundary survives a provider migration?
| Boundary | Chosen rule | Failure it contains | Rejected shortcut |
|---|---|---|---|
| Identity | Immutable account ID is the join key | Duplicate buyer or seller records | Treat email as the primary key |
| Request | Store a pending row and token hash | Lost or replayed confirmation | Update email before proof |
| Confirmation | One transaction consumes the token | Double application under retries | Separate read and write calls |
| Delivery | Provider-neutral mail and SMS adapters | Vendor lock-in in business logic | Call a vendor SDK from handlers |
| Recovery | Escalate high-risk changes to support | Takeover through a stale session | Trust a phone code alone |
The rejected option was an immediate update followed by a confirmation message. It looks attractive because the profile appears current, but a mistyped address then strands the user, and a stolen session can change the login before any new factor is checked. It is still valid for low-risk contact preferences that do not authenticate the account; it is not suitable for a login identifier attached to payouts.
I keep the table deliberately boring. In payment and ledger work, boring state transitions are easier to reconcile than clever callbacks.
How do request, confirm, and phone OTP operations stay idempotent?
The handler should create an idempotency record before sending a message. A repeated request with the same key returns the original request ID, while a different proposed address creates a new pending version and invalidates the previous token. Store only a digest of the token, use a cryptographically random value, and compare a presented digest in constant-time code where the language makes that practical. Expiry is checked at confirmation, not only by a cleanup worker.
Here is the critical path in Go. The repository method owns the transaction; the mailer is called after commit through an outbox, so a temporary delivery delay cannot roll back an identity decision.
type EmailChange struct {
ID string
AccountID string
ProposedEmail string
TokenHash []byte
ExpiresAt time.Time
ConsumedAt *time.Time
}
func (s *Service) ConfirmEmail(ctx context.Context, accountID, token string) error {
return s.repo.WithTx(ctx, func(tx *Tx) error {
change, err := tx.LockPendingEmailChange(ctx, accountID)
if err != nil {
return err
}
if change.ConsumedAt != nil {
return nil // idempotent replay
}
if time.Now().After(change.ExpiresAt) || !s.tokens.Match(token, change.TokenHash) {
return ErrInvalidConfirmation
}
if err := tx.SetAccountEmail(ctx, accountID, change.ProposedEmail); err != nil {
return err
}
if err := tx.ConsumeEmailChange(ctx, change.ID); err != nil {
return err
}
return tx.AppendAudit(ctx, AuditEvent{
AccountID: accountID,
Action: "email_changed",
ChangeID: change.ID,
})
})
}
The outbox event includes the account ID, change ID, and template version, but never the raw token. Workers can retry delivery safely because sending is not the authorization operation. The same rule applies to phone OTP: persist an attempt counter and expiry, consume a code once, and make a successful verification produce an event that names the account rather than copying mutable profile fields into downstream systems. In a migration, I would replay this sequence against a staging database with two browser sessions, a duplicate idempotency key, an expired token, and a delivery worker that is stopped after commit; the expected ledger is still one account, one consumed change, one audit event, and zero raw secrets in the outbox. That test is more informative than a green login screen because it exercises the boundaries that survive a provider swap. It doesn't matter which SMS or mail implementation sits behind the adapter if those records remain deterministic and joinable.
I once saw a migration review focus on whether the SMS vendor supported six-digit codes and miss the real bug: a retry created two pending rows, and the later confirmation selected whichever row happened to sort first. The fix was a unique pending constraint per account plus an idempotency key. The code was ordinary. The constraint was the safeguard.
What fails in production, and when should the design change?
Concurrent requests are the first test. Two browser tabs can submit different addresses; the database must serialize the winner and mark the loser superseded. The second test is a stale link: an expired or consumed token gets a neutral response and creates an audit entry, but it cannot reveal whether an address exists. The third is a session race: revoking sessions after confirmation should be an explicit policy, with trusted support sessions separated from customer sessions.
Measure the workflow as a reconciliation problem. Useful fields are request ID, account ID, policy version, token outcome, delivery attempt, confirmation latency, and support escalation reason. Alert on pending changes older than the expiry window, repeated invalid attempts, and a mismatch between consumed changes and audit events. Compliance teams may require retention limits or regional handling for email and phone data; those constraints belong in the data map and deletion policy, not in an after-the-fact dashboard.
The catch is that this design is not suitable when the product deliberately allows account merging, shared household logins, or a regulator-mandated dual-control approval for every identifier change. In those cases, use an explicit merge workflow or a second human approver. Stick with the managed provider when it is the system of record for verified factors and your team cannot yet operate token storage, delivery observability, and recovery review; migrate only the boundary you can audit.
Your mileage may vary. The right expiry, rate limits, and session policy depend on fraud data and jurisdiction, so pin them in a versioned policy and revisit them with security and compliance owners.
Top comments (0)