Short answer: choose an account-linking design that treats matching attributes as hints, requires fresh proof of control for every identity being joined, and commits consent, credential reassignment, and an audit event in one transaction.
That answer makes recovery slower. In a fintech community, it should. A member who enters a forgot-password flow may have private discussions, support history, or community roles attached to an account, so a resolver that guesses from a shared email can turn recovery into an unauthorized merge. Bot resistance matters too: discovery must reveal little, every proof must expire, and repeated attempts must become expensive without locking an entire office or household out.
The hard part isn't finding similar rows. It is deciding when the evidence is strong enough to mutate identity data, while keeping enough history for an auditor to reconstruct why the decision was allowed.
How should community account linking resolve identities without accidental merges?
Separate the community member, the login identity, and the proposed link. A member is the durable local subject that owns posts, preferences, and roles. A login identity is a credential reference such as an issuer-and-subject pair, a passkey, or a verified local address. A link proposal is temporary evidence plus intent. Collapsing those three concepts into one user row makes every later recovery rule harder to state and harder to test.
Start resolution with an exact credential identifier. A normalized email, phone number, display name, or profile resemblance may locate candidates, but none of those should authorize a link. An email can be reassigned, an address can be shared, and a profile can be copied. If an unauthenticated request supplies an address, return the same outward recovery response whether it matched zero members or several; OWASP's Authentication Cheat Sheet recommends consistent messages and timing for account-related responses so the endpoint does not become an enumeration tool.
Then branch on evidence, not similarity. If the asserted credential already belongs to one member, continue recovery for that member after the normal proof. If it belongs to none, keep account creation separate from linking. If the flow would connect two existing member IDs, stop automatic resolution and require a fresh authenticated session for both sides plus specific consent that names the destination profile. Never select the older account, the row with more fields, or the one whose email happens to match.
No guessing.
This boundary matters when forgot-password and linking meet. Imagine a member starts recovery for sam@payments.example, signs in through another credential, and sees an older community profile with the same address. The resolver can safely say that another profile may require attention, but it cannot infer common ownership from the address. It creates a short-lived proposal, binds the proposal to the initiating session, and asks for independent proof of the older profile. If the second proof is unavailable, the accounts remain separate and the case moves to a documented recovery policy. A support agent should not be able to turn a plausible story into a merge with one override click.
The consent screen must describe the actual change: which credential will attach to which member, which profile will remain the destination, and what happens to roles and active sessions. A generic Continue button is weak evidence of intent. After approval, notify the member through an already established channel and offer a controlled review path. These steps add friction, but they place friction at the exact point where a bot or mistaken human could cause irreversible identity confusion.
Model the decision as evidence, consent, and one commit
Use a state machine rather than a collection of booleans. A proposal can move from proposed to verified_both, then to approved and applied; expired, rejected, and review_required are terminal outcomes for that attempt. The transition into applied should lock the relevant member and credential records, re-check that neither credential was linked elsewhere, append an audit event, and update the proposal in the same database transaction. Notifications can be retried after commit. Identity mutation cannot be left half-done because an email or SMS delivery was delayed.
The following Python sketch keeps policy visible. It doesn't decide whether an OTP, passkey, or existing session is strong enough; that belongs in the risk policy, where compliance and security reviewers can change it without changing the resolver's invariants.
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
class LinkState(StrEnum):
PROPOSED = "proposed"
VERIFIED_BOTH = "verified_both"
APPROVED = "approved"
APPLIED = "applied"
REVIEW_REQUIRED = "review_required"
@dataclass(frozen=True)
class LinkProposal:
proposal_id: str
source_member_id: str
target_member_id: str
expires_at: datetime
state: LinkState
source_proof_id: str | None
target_proof_id: str | None
consent_event_id: str | None
def ready_to_apply(proposal: LinkProposal, now: datetime) -> bool:
return all(
(
proposal.source_member_id != proposal.target_member_id,
proposal.state == LinkState.APPROVED,
proposal.expires_at > now,
proposal.source_proof_id is not None,
proposal.target_proof_id is not None,
proposal.consent_event_id is not None,
)
)
The application service should make the command idempotent. Retrying the same proposal after a client timeout must observe applied and return the existing result, not append another link event. A different proposal that races for the same external identity should lose at a database uniqueness constraint on the credential's stable issuer-and-subject key. Treat that constraint as the final guard, not as a replacement for the policy checks that explain why the operation was legitimate.
Retries happen.
Audit data should answer a narrow set of questions: who initiated the proposal, which two local members were involved, which proof records satisfied policy, when explicit consent occurred, which policy version evaluated the request, and which transaction applied it. Store references to proof events rather than raw secrets or OTP values. Retention, access, and deletion rules should be agreed with compliance and privacy owners; an audit trail that exposes authentication material creates a second problem while trying to solve the first.
I pay special attention to delivery gaps here because an OTP that arrives late can cross a state boundary. The verifier must bind a code to one proposal, one purpose, and one expiration, then consume it once. A code issued for password reset must not satisfy account-link consent. Don't let resend create several simultaneously valid proofs, and don't make an SMS or email provider callback the authority for whether a link was committed. Delivery reports describe transport; the identity database owns the decision.
Abuse controls and tests define the real selection criteria
Evaluate an implementation by its failure behavior before comparing its happy-path ergonomics. Rate limits need several dimensions: member, credential identifier, proposal, network signal, and time window. A single IP limit is too coarse for shared networks, while a member-only limit lets a bot distribute discovery across candidate accounts. The public response should stay consistent, but internal events should distinguish no match, ambiguous candidates, expired proof, replayed proof, policy rejection, and concurrency conflict. A client can receive a generic accepted response while operators retain precise, access-controlled evidence.
The most useful test suite attacks invariants. Generate duplicated normalized emails, shared phone numbers, changing display names, expired proposals, proof replay, and two workers approving the same link. Assert that one external credential maps to at most one member; that no proposal reaches applied without two valid proof references and consent; that moderator or staff privileges do not silently move through an ordinary recovery path; and that a retry produces no second audit event. Include session handling: after a sensitive recovery or link, the policy should decide which existing sessions are revoked and record that decision.
There is no universal threshold for manual review. I'm not sure a low-risk discussion board and a regulated customer community should ever share one, and the evidence needed to settle that choice is local: account value, role sensitivity, recovery volume, delivery reliability, fraud signals, and the review team's capacity. The architecture should therefore expose a policy decision such as allow, deny, or review_required without letting the resolver reinterpret it. Your mileage may vary, but the invariant cannot.
Observe ratios rather than celebrating raw completion count. Useful measures include proposals started, both-side verification completed, proposals expired, review required, links applied, links later challenged, and recovery attempts throttled. Slice them by proof type and risk class without placing sensitive identifiers in metric labels. A sudden rise in proposals followed by password resets deserves investigation even when each individual request stayed below its rate limit.
Proof expires.
The catch is operational cost. Dual proof, immutable audit events, notification retry, abuse telemetry, and a review queue demand more engineering and support capacity than matching on email. This design is not suitable when the organization cannot protect audit data, operate a second proof, or review ambiguous ownership claims. In that situation, keep the accounts separate and provide a controlled data export or support-mediated access recovery that does not combine identities. For a low-value community with no private data or privileged roles, a simpler no-merge policy may be safer than building a sophisticated linker nobody can operate well.
Roll out from observation to reversible linking
Begin with a read-only resolver that records candidate outcomes without creating proposals or changing members. Review ambiguous cases, especially shared addresses and privileged profiles, then set policy thresholds from the observed distribution. Next, enable proposals for a small cohort while preserving the ability to detach a newly associated credential through an audited administrative action. Rollback should reverse the association event, not delete either member or rewrite history.
Keep the migration compact: add stable credential records, enforce their uniqueness, introduce proposal and audit-event tables, shadow existing recovery decisions, and only then enable transactional application. Old email-based joins should become search aids, never authorization rules.
The final release criterion is plain: every applied link can be replayed from policy version, independent proofs, explicit consent, and one committed event. If the team cannot show that chain during an audit, the resolver is still a matcher, not an identity control.
Top comments (0)