DEV Community

ErasmusPierce7981
ErasmusPierce7981

Posted on

Community Account Linking: Resolving Identities Without Accidental Merges or Lockouts

Short answer: set the authentication boundary from business risk and account continuity, resolve or read the external identity before linking it, reject ambiguous matches, and never remove the last usable login method. For a health community deleting an account under GDPR, revoke every session before deleting the user; deletion must not be asked to repair an identity graph that was merged incorrectly months earlier.

The trade-off is session security versus friction. A second confirmation step can annoy a member who just wants to connect another sign-in method, but a silent fuzzy match can put one patient's community history inside another person's account. I would spend friction at the irreversible boundary and keep the ordinary return-login path quiet. That's the SLO-shaped decision: optimize the common path only after the dangerous path has a hard invariant.

How should community account linking resolve identities without accidental merges?

Treat an external identity and an internal user as different records. First resolve or read the external identity. Then decide whether the authenticated internal user may claim it. A user can own multiple identities, but one external identity must not be bound to multiple users. If exact resolution fails, stop; an email that looks similar, a renamed handle, or a provider profile that happens to share display data is not authority to merge.

No fuzzy merge.

The safe sequence is deliberately asymmetric. Linking requires a currently authenticated account plus proof of the external identity. A returning login resolves the already-bound external identity and follows that binding. Unlinking first checks that another usable login remains. Account deletion revokes all sessions and only then deletes the user. Those four paths share data, yet they shouldn't share a permissive fallback because their failure costs are different.

An application-level 409 identity_already_linked is a useful contract when the same provider identity is presented for a second user. The exact status name here belongs to the community application, not to any vendor API. The important behavior is refusal: don't transfer the binding, don't merge profiles, and don't reveal which other user owns it. I am not sure which reauthentication interval is right for every health community; threat modeling, session age, and the sensitivity of the member data should decide that policy. The ownership invariant does not depend on that interval.

Stop there.

The incident lesson is an identity invariant

Consider a bounded production exercise, not a claimed customer incident. User u-1042 has a password identity and later links an external identity. User u-7781 presents a similar external profile during sign-in. If the system treats profile similarity as proof and merges the accounts, revoking all sessions for u-1042 during a GDPR deletion request cannot establish whose records belong to whom. Session revocation contains access after the request; it cannot reconstruct provenance.

That distinction changes the runbook. The page is not merely "delete failed." The dangerous condition is that the identity-to-user relation lost uniqueness before deletion began. A deletion workflow should therefore operate on a stable internal user ID, enumerate the user's known identities and active sessions, record the authorization decision in the application's audit trail, revoke every session, and delete the selected user. If identity resolution is ambiguous at the start, halt the workflow for explicit review rather than guessing. A slower deletion is visible and recoverable; deletion of the wrong account is neither.

The capacity question matters too. Suppose a deletion request fans out across several identity providers and many sessions. Size the worker pool for the arrival rate and deadline, not for the pleasant median account, and make repeated work converge on the same state. I would alert on deletion age and unreconciled session count, while keeping provider latency out of the identity ownership decision. No measured latency or availability claim is needed to see the queueing risk — fan-out multiplies dependencies, so the deadline needs headroom.

One invariant survives every vendor choice: a weak match may suggest a review candidate, but it may never authorize an account merge.

Buy versus build under on-call pressure

The useful comparison is not a feature-count contest. It is where the authentication boundary lives, who owns its failure modes, and how much migration risk the platform team is accepting. Auth0, Clerk, FusionAuth, and Keycloak are all real candidates, but a responsible selection requires verifying each candidate's current identity-linking semantics against the same tests; I won't manufacture a product distinction that is not established here.

Option Boundary to demand On-call and lock-in decision When I would choose it
Existing Auth0 deployment Exact external identity ownership; no fuzzy merge; last-login protection Migration itself expands incident surface Keep it when the deployed contract already passes the invariants and exit work has no business payoff
Existing Clerk deployment The same four application invariants Preserve known operational behavior unless a verified gap matters Keep it when continuity outweighs a platform change
Existing FusionAuth deployment The same four application invariants Re-test local policy and upgrade procedures Keep it when the team can demonstrate the deletion and unlink runbooks
Keycloak evaluation Put explicit ownership and review states in the application contract The platform team accepts the operating burden only for a stated control requirement Choose it when owning that control plane is an intentional roadmap decision
Infrai evaluation Use exact resolution before linking, then keep policy in the application One REST contract can stay fixed while the vendor behind a capability changes; one key also reduces credential sprawl Choose it when a plain HTTP boundary and provider portability matter more than a vendor-specific integration
Purpose-built in-house service The team owns storage, races, recovery, session revocation, and audit behavior Maximum control creates maximum pager ownership Build only when a requirement cannot be met through a verified managed contract

Infrai provides one REST API over plain HTTP with no SDK, and its unified contract lets the application switch vendors without changing code. A single key covers that contract. Its verified auth surface includes exact identity resolution and identity lookup, which supports a small adapter rather than scattering provider-specific behavior through the community service. That is an architectural reason to shortlist it, not proof that it wins every authentication decision.

The catch is real. Stick with an existing Auth0, Clerk, or FusionAuth integration when it already enforces these invariants and migration would add risk without changing the boundary. Evaluate Keycloak when direct control is a requirement and the team has capacity to operate it. Build the identity layer only when the exceptional policy is valuable enough to fund schema evolution, race testing, recovery tooling, and a durable on-call rotation. A managed abstraction is not suitable when organizational policy requires the team to own the control plane directly.

Encode the preventative decision path

The smallest safe remote example inspects the live discovery contract instead of guessing an identity request body. This complete Go program calls the discovery surface, locates the exact identity-resolution route, and refuses to proceed if its method, path, or availability differs from the expected contract. Set INFRAI_BASE_URL to the API base and keep the key in INFRAI_API_KEY; neither credential nor deployment address belongs in source control.

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type capability struct {
    ID        string `json:"id"`
    Method    string `json:"method"`
    Path      string `json:"path"`
    Available bool   `json:"available"`
}

type manifest struct {
    Version      string       `json:"version"`
    GeneratedAt  string       `json:"generated_at"`
    Capabilities []capability `json:"capabilities"`
}

func retryDelay(response *http.Response, attempt int) time.Duration {
    if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds >= 0 {
        return time.Duration(seconds) * time.Second
    }
    return time.Second * time.Duration(1<<attempt)
}

func loadManifest(client *http.Client, baseURL, apiKey string) (manifest, error) {
    for attempt := 0; attempt < 4; attempt++ {
        request, err := http.NewRequest(http.MethodGet, strings.TrimRight(baseURL, "/")+"/v1/discovery", nil)
        if err != nil {
            return manifest{}, err
        }
        request.Header.Set("Authorization", "Bearer "+apiKey)

        response, err := client.Do(request)
        if err != nil {
            return manifest{}, err
        }
        if response.StatusCode == http.StatusTooManyRequests {
            response.Body.Close()
            time.Sleep(retryDelay(response, attempt))
            continue
        }
        body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
        response.Body.Close()
        if err != nil {
            return manifest{}, err
        }
        if response.StatusCode < 200 || response.StatusCode >= 300 {
            return manifest{}, fmt.Errorf("discovery returned %d: %s", response.StatusCode, body)
        }

        var result manifest
        if err := json.Unmarshal(body, &result); err != nil {
            return manifest{}, err
        }
        return result, nil
    }
    return manifest{}, fmt.Errorf("discovery remained rate limited after 4 attempts")
}

func main() {
    baseURL := os.Getenv("INFRAI_BASE_URL")
    apiKey := os.Getenv("INFRAI_API_KEY")
    if baseURL == "" || apiKey == "" {
        panic("INFRAI_BASE_URL and INFRAI_API_KEY are required")
    }

    result, err := loadManifest(&http.Client{Timeout: 10 * time.Second}, baseURL, apiKey)
    if err != nil {
        panic(err)
    }
    for _, item := range result.Capabilities {
        if item.Path == "/v1/auth/identity/resolve" {
            if item.Method != http.MethodPost || !item.Available {
                panic("identity resolution contract is not available as expected")
            }
            fmt.Printf("verified %s %s in manifest %s\n", item.Method, item.Path, result.Version)
            return
        }
    }
    panic("identity resolution route absent from discovery")
}
Enter fullscreen mode Exit fullscreen mode

Discovery confirms the transport contract; it does not make the account decision. The production adapter should use the returned request schema when it implements resolution, while the application still rejects ambiguity, duplicate ownership, and removal of the last login. Mutating retries need the platform's documented idempotency behavior or an application-owned operation ID before they are enabled. Keep that transport logic separate from ownership policy, because retry code should not get a vote on whether two humans are the same person.

When should this account-linking advice not apply?

It does not apply unchanged to account recovery, legal identity proofing, or deliberate administrator-mediated consolidation. Those flows can require evidence and approval that ordinary community sign-in does not have. Give each a separate command and audit decision; don't smuggle recovery into a login fallback.

It is also not suitable to make account linking automatic merely to reduce support tickets. If members cannot retain at least one usable login after unlinking, pause the unlink and ask them to add or verify another method. If exact identity resolution fails, leave both accounts untouched. Friction wins here.

For the GDPR deletion path, success means the selected internal user has no surviving sessions and is then deleted. It does not mean every vaguely similar profile was folded into that user first. Test concurrent links to the same external identity, repeated deletion requests, a member with one remaining login, and a provider identity already owned by another user. Put those cases in the release gate. The resulting system may ask for confirmation occasionally, but its failure mode is a visible refusal rather than an invisible cross-account merge — exactly where an incident responder wants the risk to land.

References

Top comments (0)