Short answer: a community should link accounts only after the user proves control of both identities in the same authenticated flow; matching email addresses, profile fields, or provider claims is discovery evidence, never merge authority. During a managed-provider exit, keep an immutable link ledger, enforce one owner per external identity in the database, and make deletion plus session revocation a separate idempotent workflow.
That boundary matters in a logistics community because a mistaken merge can expose saved routes, driver discussions, or account controls to the wrong person. The migration makes the risk sharper: old and new subject identifiers coexist while background jobs are already moving data. I've been paged for missed jobs and duplicate deliveries, and identity work has the same unforgiving shape — retries are normal, but an ambiguous side effect is not.
How should a community resolve identities during account linking?
Treat resolution and linking as two different operations. Resolution finds candidate profiles. Linking changes who can authenticate as whom. A normalized email can help locate a candidate, but it cannot authorize the state change because email ownership, address reuse, aliases, and provider-specific verification semantics are outside the application database's control. OWASP's authentication guidance recommends reauthentication for sensitive account changes and warns against relying on weak recovery paths. Account linking belongs in that sensitive-change category.
The safe invariant is narrow: the current session has recently authenticated account A, a separate challenge proves control of external identity B, and the transaction records that B has exactly one local owner. If B already belongs to another local account, stop. Don't silently choose the older profile, the profile with more posts, or the one whose email string happens to match. Send the case to a recovery process that gathers stronger evidence without exposing whether another account exists.
This is also where the logistics deletion requirement enters the model. A deletion request targets the local person record, every external identity attached to it, every active session, and every queued erasure task. It does not infer targets from email at execution time. Email is mutable; the recorded ownership graph is the durable input.
A useful data model has four concepts: a stable local account ID, an external identity key made from issuer plus subject, a short-lived proof bound to the intended local account, and an append-only link event. Store mutable profile data separately. The event needs who initiated the change, which authenticated session approved it, the proof method, and timestamps, but it should not contain bearer tokens or raw secrets.
One rule does most of the safety work: (issuer, subject) is unique across active links. A second rule prevents proof replay: a proof identifier can be consumed once. Those constraints belong in storage, not only in Node.js application code, because two callbacks can pass an application-level check before either write commits.
No guessing.
The incident lesson is about authority, not string matching
The bounded production scenario I use for this decision is a managed-provider migration for a logistics community while account deletion and session revocation continue to run. The operational history behind the caution is straightforward: cron and queue systems can miss a job or deliver it twice. A migration worker therefore cannot assume one callback, one message, or one clean ordering between the legacy provider and the replacement.
At first glance, an email match looks like a convenient bridge between provider subject IDs. It is also exactly the wrong authority boundary. Imagine the legacy export presents a community profile with one provider subject, while the new login produces another subject and the same normalized email. An automatic merge would convert correlation into authentication. If the evidence is stale or the address changed hands, the wrong person gains the established profile. If a deletion job races that merge, the system may revoke the old sessions while creating a fresh path into an account that should be closing.
Walk that race through the queue before choosing a design. The migration worker reads the legacy mapping and schedules a link. The member then requests account deletion, so the application marks the local account as closing and schedules revocation. A delayed login callback arrives with the same email but a new provider subject. If each handler checks only the profile row it first read, all three can believe their action is allowed: the migration attaches the old identity, deletion revokes the sessions it knew about, and the callback attaches a new identity after that snapshot. There is no single dramatic error to alert on, yet the closing account has acquired a fresh authentication path. Binding the proof to the local account, checking the closing state inside the same serializable transaction, and making the identity key unique turns this from an ordering puzzle into a commit decision. Either the link wins and deletion observes it as a target, or closure wins and the link is rejected. The queue may still deliver twice. The ownership result cannot split.
Proof beats correlation.
The invariant revealed by this scenario is stronger than “verified email required.” A link is allowed only when a fresh proof is explicitly bound to the target local account and the external identity is unowned at commit time. The provider's email-verification signal can be one input to a recovery policy, but it doesn't replace proof of both sides for an authenticated link. I'm not sure which historical profiles are safe to consolidate when the old provider export lacks trustworthy issuer-and-subject history; that uncertainty can be resolved only with a new user proof or a documented support review. A migration deadline cannot manufacture evidence.
The same postmortem rule applies to deletion: mark the account as closing before enqueueing remote cleanup, reject new link attempts in that state, revoke local sessions synchronously where the architecture permits, and let an idempotent worker finish external revocation and erasure. Record completion per target. A retry then observes completed steps and advances the remainder instead of repeating an opaque “delete everything” call.
Put the invariant in the commit path
The preventative path below is deliberately smaller than a complete authentication service. The Node.js community application can call an internal linking boundary, while the transaction logic is shown in Go to make ownership and failure paths explicit. The interfaces stand for your chosen database and proof verifier; the important part is their contract.
package identity
import (
"context"
"errors"
"time"
)
var (
ErrProofInvalid = errors.New("link proof is invalid")
ErrAccountClosing = errors.New("account is closing")
ErrIdentityOwned = errors.New("external identity already has an owner")
)
type ExternalIdentity struct {
Issuer string
Subject string
}
type LinkProof struct {
ID string
TargetAccountID string
Identity ExternalIdentity
ExpiresAt time.Time
}
type Tx interface {
AccountAcceptsLinks(context.Context, string) (bool, error)
ConsumeProof(context.Context, string, string, time.Time) (bool, error)
InsertUniqueLink(context.Context, string, ExternalIdentity) error
AppendLinkEvent(context.Context, string, ExternalIdentity, string) error
}
type Store interface {
WithSerializableTx(context.Context, func(Tx) error) error
}
type Service struct {
store Store
now func() time.Time
}
func (s *Service) Link(ctx context.Context, accountID string, proof LinkProof) error {
now := s.now()
if proof.TargetAccountID != accountID || !now.Before(proof.ExpiresAt) {
return ErrProofInvalid
}
return s.store.WithSerializableTx(ctx, func(tx Tx) error {
open, err := tx.AccountAcceptsLinks(ctx, accountID)
if err != nil {
return err
}
if !open {
return ErrAccountClosing
}
consumed, err := tx.ConsumeProof(ctx, proof.ID, accountID, now)
if err != nil {
return err
}
if !consumed {
return ErrProofInvalid
}
// Storage maps a uniqueness conflict to ErrIdentityOwned.
if err := tx.InsertUniqueLink(ctx, accountID, proof.Identity); err != nil {
return err
}
return tx.AppendLinkEvent(ctx, accountID, proof.Identity, proof.ID)
})
}
The handler should return a generic conflict to the browser and log a stable internal reason code. It should not reveal the owner account. The unique constraint decides the race, and the transaction ensures a failed insert does not consume the proof. If the client loses the response after commit, it can fetch its own linked identities and see the result; repeating the proof must not create another event.
Test the ugly interleavings, not just the happy callback. Run two link attempts for the same issuer and subject against different accounts, pause both after their initial reads, then release both writes. Exactly one commit may own the identity. Start account closure while a link transaction is waiting and verify that your chosen serialization order produces either a completed link followed by complete deletion, or a closing account with no new link.
Never accept a half-owned identity.
One owner. Always.
Migration choices, operations, and limits
There are three common strategies, and none is universally correct. Automatic matching is fast but unsuitable for establishing authentication authority. Forced relinking asks every returning member to prove both sides; it creates support load and abandonment, yet it gives the cleanest evidence. A staged approach imports local accounts as inert records, maps only external identities backed by stable issuer-and-subject data, and requires fresh proof for ambiguous cases. For a community that must keep deletion and revocation correct during a provider exit, the staged approach is usually the defensible default.
| Strategy | Authority evidence | Main operational risk | Appropriate condition |
|---|---|---|---|
| Email-based automatic merge | Correlation only | Wrong-account takeover | Never as final link authority |
| Forced user relinking | Fresh control of both sides | Support volume and inactive accounts | High-risk profiles or weak exports |
| Staged migration | Stable mappings plus fresh proof for gaps | Longer dual-system period | Auditable subject history exists |
The catch is that staged migration keeps two identity domains alive longer. That means more runbook states, reconciliation metrics, and on-call decisions. Stick with forced relinking when the export cannot preserve stable issuer-and-subject pairs, when support can absorb the load, or when account value makes ambiguity unacceptable. Automatic migration is reasonable only for records whose ownership mapping is already authoritative; an email match by itself does not meet that bar.
Before cutover, inventory every place that treats a provider subject as the primary key. Give the local account its own stable ID. Shadow-read mappings, but keep one writer. Rehearse rollback without reversing committed links: traffic can return to the old login entry point, while the ownership ledger remains monotonic. Deleting link history during rollback would erase the evidence needed to explain a conflict later.
Expose a small set of low-cardinality metrics: link attempts by outcome, proof expiry, ownership conflicts, recovery referrals, queue age for account closure, and the count of closing accounts with unfinished targets. Alert on age and invariant violations, not raw traffic. Logs should join the local account ID, proof ID, link event ID, and deletion workflow ID while excluding session tokens and authentication secrets.
A reconciliation job should compare the ownership ledger, active-session index, and deletion state. It must be safe to rerun. Use a checkpoint and deterministic work keys, then record each completed side effect before acknowledging the queue message. Duplicate delivery becomes boring; a missed schedule becomes visible as queue age rather than a silent privacy gap. This is the idempotency reflex that cron and queue incidents teach quickly.
The runbook needs explicit decisions. For a spike in ownership conflicts, stop automated migration for the affected issuer and preserve evidence; don't broaden matching rules during an incident. For a growing deletion backlog, protect new deletion intake, scale the idempotent workers within downstream limits, and verify session revocation separately from profile erasure. For proof-expiry spikes, inspect clock synchronization and delivery latency before increasing lifetime, because a longer proof window changes security exposure.
Cost is mostly operational here. Count the dual-run duration, recovery reviews, support contacts, proof delivery, audit retention, and engineering time for reconciliation. A provider quote is only one row. The cheapest migration plan on a spreadsheet can be expensive on call if it leaves operators unable to answer “who owns this subject?”
This design is not suitable when the application cannot provide transactional uniqueness or an equivalent single-owner coordinator. In that environment, pause linking during migration or introduce a serialized ownership service before accepting concurrent callbacks. It is also heavier than necessary for a disposable forum with no private data, no retained identity, and no account recovery; even there, silent email merges remain a poor authentication boundary.
The decision rule is short: if the system cannot show the proof, the intended target, and the single-owner commit for each link, it is not ready to merge identities.
References
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
Top comments (0)