Short answer: for loyalty account deduplication, resolve identity before user creation: normalize the phone, check existing members, require a second signal for ambiguity, and create only a provisional account when no safe link exists.
The hard part of phone OTP login in a loyalty product is deciding if a phone number belongs to an existing member before creating another account, while leaving that member a credible recovery path. A two-stage identity gate protects points, avoids account splits, and gives support a traceable explanation when records collide.
Incident lesson: the duplicate was a recovery failure
During a membership migration, I treated a successful OTP challenge as proof that a new user could be created. A customer had changed their number, then enrolled with the recycled old number; the system created a second loyalty record with zero history. The request returned 201, so the dashboard looked healthy.
The next morning, the customer could pass OTP on either record but could not recover the points balance without manual proof. Support had to compare an old receipt, a shipping address, and a rewards statement while the redemption queue kept moving. That incident changed the invariant: authentication proves control of a factor, not ownership of a loyalty identity, and every automated link therefore needs a reversible decision record, a recovery owner, and a deadline for unresolved cases rather than an optimistic database insert.
That is the whole trap.
The creation transaction must receive a resolution decision, not merely a verified phone number. A match can be automatic when normalized phone and verified email agree. A phone-only match should pause for step-up verification or support review. No match should create a provisional identity with an explicit merge path, rather than silently copying rewards.
The operational signal is an SLO for safe enrollment, not just OTP delivery latency. I track canonical-member creation, ambiguous matches, and recovery time after a number change. A 99.9% delivery SLO cannot hide a 2% duplicate-account rate.
Measure the merge, too.
How should loyalty teams resolve identity before creating accounts?
Store phones in E.164 form, preserve original input for audit, and compare identifiers only within the tenant and region rules that produced them. Do not use fuzzy name matching as an auto-link rule; names change, transliteration is lossy, and a false merge is harder to undo than a duplicate.
A decision record contains candidate member IDs, signals considered, policy version, and reason code. Keep it immutable. The user-creation transaction consumes that record with an idempotency key, so a retry cannot create a second member while the first request is being acknowledged.
Small detail, large blast radius.
type Resolution string
const (
CreateProvisional Resolution = "create_provisional"
LinkExisting Resolution = "link_existing"
NeedsReview Resolution = "needs_review"
)
type Candidate struct {
MemberID string
PhoneE164 string
PhoneVerified bool
EmailHash string
}
func resolve(phone, emailHash string, candidates []Candidate) Resolution {
for _, c := range candidates {
if c.PhoneE164 == phone && c.PhoneVerified && c.EmailHash == emailHash {
return LinkExisting
}
}
if len(candidates) == 0 { return CreateProvisional }
return NeedsReview
}
The code deliberately returns review for a phone-only collision. In production, the review queue needs expiry, rate limits, and a replay-safe worker; otherwise a temporary state becomes a permanent second account. I'm not sure one global threshold works across every country, because number reassignment and household sharing vary, so I would calibrate policy from dispute outcomes.
Buy versus build for the identity gate
| Approach | Useful boundary | Main trade-off |
|---|---|---|
| Build a policy service | Stable identifiers and one tenant model | You own normalization, merge tooling, abuse controls, and on-call |
| Managed identity provider | Fast OTP enrollment and standards-based flows | Loyalty-specific linking and recovery remain application work |
| Data-matching platform | Large historical datasets | Explainability, consent, and false-positive review add governance |
Auth0, Amazon Cognito, and Firebase Authentication can handle common OTP plumbing. Their useful boundary is factor verification; the canonical loyalty-member decision still belongs in your domain. Their subject IDs and export models differ, so switching later can require migration of recovery metadata.
Most test suites assert that a valid code leads to a 200 response. Add contract tests where the correct result is no account created: recycled numbers, shared family phones, changed email, duplicate idempotency keys, and a merge request arriving during redemption. Property tests should verify stable normalization and tenant isolation.
For observability, emit a redacted resolution event with correlation ID, policy version, candidate count, and disposition. Never log the OTP or raw phone number. Alert on review volume, merge reversals, and recovery tickets per thousand enrollments.
The catch is that a review queue adds friction. It is not suitable when a product promises anonymous, instant point-of-sale enrollment; issue a limited provisional wallet and collect a second factor later. Stick with phone-only when the account has no transferable value and recovery is intentionally disposable. For a loyalty ledger with points or vouchers, the extra step is cheaper than an irreversible false merge.
Top comments (0)