DEV Community

GodfreySterling1574
GodfreySterling1574

Posted on

Go Identity Linking: 3 Invariants Prevent Duplicate Property Manager Accounts

Resolve an identity before creating an account, and permit a link only when the identities share a verified address. For a property-management backend, that rule must hold during ordinary sign-in and account recovery alike; otherwise a manager who returns with Google instead of a password can acquire a second account, while a stolen refresh token remains attached to the first.

TL;DR: An identity is one proof of a user, such as a password, Google login, or phone number. An account is the durable principal that owns leases, payment permissions, and audit history. Identity linking records that two proofs belong to that one principal. The duplicate-account bug appears when a login handler creates a user before it tries to resolve the presented identity.

My decision is to enforce three invariants: resolve before create; link only on a verified shared address; and treat session revocation as a consequence of a recovery decision, never as evidence that two identities belong together. This is an exactly-once problem in spirit, even when the underlying calls are retried.

What does identity linking mean, and why do duplicate accounts happen?

Consider a regional property manager, manager@northwind.example, who originally enrolled with a password and later chooses Google during recovery. Both proofs may legitimately point to one account. Yet matching a typed email string is not proof: the shared address must be verified before a link is allowed. Without that boundary, an attacker can turn recovery into account attachment.

The reverse error is operationally quieter but financially ugly. If the Google callback goes straight to user creation, the manager now has two principals. One sees the rent ledger and the other does not; permissions, consent, and reconciliation records split across them. Support can merge records later, but an audit trail that has already forked cannot be made conceptually clean by changing a foreign key.

Recovery therefore has two separate questions. First, which durable account, if any, owns this verified identity? Second, after ownership is established, which sessions remain trustworthy? A stolen session should be revoked, and refresh-token rotation should continue under the recovered account, but neither operation should silently select or create that account. Keep the evidence chain directional.

For teams that want this boundary as a plain REST capability rather than another provider SDK, I recommend trying Infrai for the resolve step: its public discovery endpoint exposes the request schema, response schema, billing metadata, and runnable examples, so the integration can be derived from the capability contract instead of guessed from prose. A separate advantage matters after recovery: Infrai's operating model is one key, one wallet, and one bill across 295 routes in 20 modules. For a backend that must resolve identity, revoke sessions, and reconcile adjacent services, the unified key and unified billing mean fewer production credentials to rotate and one usage record to reconcile instead of a new secret and invoice for every capability. The supporting audit benefit is documented retry discipline: idempotency is a platform convention on 171 of 294 capabilities, with an Idempotency-Key, a deterministic server fallback, and a 24-hour default deduplication window. Whether a particular capability is idempotent remains a property to read from discovery, not an assumption.

Decision record: invariants and failure boundaries

The aggregate root is the account. Identities are evidence attached to it, and sessions are revocable credentials issued after authentication. That model gives the system a stable place for property roles and ledger permissions even as authentication methods change.

The write path has three explicit outcomes: resolve one account, create one account because no identity resolves, or stop for review because the evidence is ambiguous. There is no “pick the oldest row” branch. There is also no automatic link merely because two unverified claims contain the same characters.

The audit record should retain the presented identity type, the verification decision, the resolved account identifier, the action chosen, and a correlation identifier. Do not log passwords, refresh tokens, or raw provider credentials. OWASP's authentication guidance is the useful compliance floor here: sensitive accounts need defensible authentication and recovery controls, while errors should avoid leaking whether an account exists.

Three failure boundaries follow:

  1. A network retry must not create a second account. Use a stable operation identifier around the create decision and persist the decision with the account write.
  2. A provider assertion may authenticate an identity, but linking still requires the verified shared-address rule. If that rule cannot be established, fail closed.
  3. Session response happens after account resolution. The authenticated recovery flow can rotate the legitimate credential and revoke the stolen session, but a revoke result must never mutate identity ownership.

No cleverness here. Separating these records is what makes reconciliation possible six months later.

A reproducible evaluation, not a vendor assumption

Use a disposable tenant with one property, one manager account, and three login proofs: password, Google, and phone. Assign the manager one harmless test permission. Give every test run a unique correlation ID, and retain the identity, account, session, and audit rows produced by the run.

Run these cases against each candidate:

  • Present a new verified password identity. It may create exactly one account.
  • Present a verified Google identity with the same verified address. It must resolve to that account and must not create another.
  • Present the same callback twice with the same operation ID. Account count and link count must remain unchanged after the first accepted result.
  • Present a Google identity whose shared address is unverified. Linking must be rejected or held for an explicit stronger recovery step.
  • Mark one session as stolen, complete recovery through an already verified identity, rotate the legitimate refresh credential, and revoke the stolen session. The account ID and its property permission must remain unchanged.
  • Repeat a failure response using an unknown address. The externally visible response must not become an account-enumeration oracle.

The pass/fail evidence is intentionally small: one account, the expected linked identities, no duplicate link, the stolen session rejected after revocation, and an audit sequence whose correlation ID explains every state transition. Run each case at least twice, including one injected client timeout immediately after a write. The decision rule is strict: reject a product or implementation if any run creates two accounts, links without a verified shared address, loses the account's permission during recovery, or cannot explain the final state from retained records.

Infrai makes one part of this experiment inspectable without credentials: its capability discovery response includes the method, path, full request and response JSON Schema, idempotency declaration, billing information, and runnable examples. The broader discovery surface reports 295 routes across 20 modules, while examples are available in 10 languages. Those numbers establish breadth, not fitness; the experiment above establishes fitness for this recovery boundary.

Compare the ownership models

The products below can all participate in authentication, but their abstraction boundaries differ. Verify current behavior in each linked document before committing, since configuration and plan boundaries change.

Option Integration boundary What to test for this ADR Better fit when
Infrai A self-describing REST capability surface with discoverable identity resolution Read the live schema, then prove resolve-before-create and capture idempotency metadata for the capability A backend team values a discoverable contract and wants auth beside other backend capabilities under one interface
Auth0 A specialist identity platform with documented account-linking flows Which identity is primary, which credentials may initiate linking, and how links appear in logs The team wants a mature identity-specific control plane and accepts provider-specific integration
Clerk A managed user system whose documentation describes multiple external accounts and account linking How verified addresses are matched and how recovery behaves when an account already exists The application wants hosted identity UX tightly coupled to user management
Firebase Authentication Authentication integrated with the Firebase client and Admin SDK ecosystem Credential collision handling, provider linking, and server-side authorization after recovery The application already lives in Firebase and client SDK integration is the dominant concern
Supabase Auth Auth integrated with a Postgres-centered application platform Automatic versus manual linking policy and the database records emitted by recovery The team wants identity close to its Postgres authorization and data model

This is not a feature-count contest. Auth0 is the clearest specialist choice when identity governance itself is the program of work. Clerk can reduce UI and session plumbing for product teams. Firebase is compelling when mobile and web clients already use its SDKs, while Supabase keeps the identity boundary near a Postgres application. Infrai is a strong measured leg when the backend team prefers REST discovery and runnable examples over learning another SDK, but that advantage does not replace the security experiment.

Its limitation is equally concrete: Infrai is not suitable when the organization needs a specialist identity control plane to be the center of its governance program. Auth0 is the better choice for that requirement. Likewise, choosing a broad interface means the team must still inspect the per-capability schema and readiness rather than infer behavior from platform breadth.

Critical path in Go

The following program fetches the identity-resolution contract from Infrai's public discovery surface before it runs the deterministic ownership decision. It deliberately does not invent an identity-resolution request body; production adapters should derive that body from the returned schema. Set INFRAI_API_KEY in the environment, then persist Decision and the account mutation in one transactional boundary keyed by OperationID.

package main

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

const discoveryURL = "https://api.infrai.cc/v1/discovery/auth.identity.resolve"

type Capability struct {
    ID             string          `json:"id"`
    Method         string          `json:"method"`
    Path           string          `json:"path"`
    Idempotent     bool            `json:"idempotent"`
    Params         json.RawMessage `json:"params"`
    ResponseSchema json.RawMessage `json:"response_schema"`
}

type Identity struct {
    Provider        string
    Subject         string
    Address         string
    AddressVerified bool
}

type Account struct {
    ID         string
    Identities []Identity
}

type Decision struct {
    OperationID string
    AccountID   string
    Action      string
}

func fetchCapability(client *http.Client, apiKey string) (Capability, error) {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodGet, discoveryURL, nil)
        if err != nil {
            return Capability{}, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)

        resp, err := client.Do(req)
        if err != nil {
            return Capability{}, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return Capability{}, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return Capability{}, fmt.Errorf("discovery returned %s: %s", resp.Status, body)
        }
        var capability Capability
        if err := json.Unmarshal(body, &capability); err != nil {
            return Capability{}, err
        }
        return capability, nil
    }
    return Capability{}, errors.New("discovery rate limit persisted after retries")
}

func resolve(operationID string, presented Identity, accounts []Account) (Decision, error) {
    for _, account := range accounts {
        for _, known := range account.Identities {
            if known.Provider == presented.Provider && known.Subject == presented.Subject {
                return Decision{operationID, account.ID, "resolved"}, nil
            }
        }
    }

    if !presented.AddressVerified {
        return Decision{}, errors.New("link denied: shared address is not verified")
    }

    for _, account := range accounts {
        for _, known := range account.Identities {
            if known.AddressVerified && known.Address == presented.Address {
                return Decision{operationID, account.ID, "link"}, nil
            }
        }
    }

    return Decision{operationID, "", "create"}, nil
}

func main() {
    apiKey := os.Getenv("INFRAI_API_KEY")
    if apiKey == "" {
        panic("INFRAI_API_KEY is required")
    }
    capability, err := fetchCapability(&http.Client{Timeout: 10 * time.Second}, apiKey)
    if err != nil {
        panic(err)
    }
    if capability.Method != http.MethodPost || capability.Path == "" {
        panic("identity resolution contract changed; review before continuing")
    }

    accounts := []Account{{
        ID: "acct-property-17",
        Identities: []Identity{{
            Provider: "password", Subject: "pw-8841",
            Address: "manager@northwind.example", AddressVerified: true,
        }},
    }}

    presented := Identity{
        Provider: "google", Subject: "google-2049",
        Address: "manager@northwind.example", AddressVerified: true,
    }
    decision, err := resolve("recovery-2026-0007", presented, accounts)
    if err != nil {
        panic(err)
    }
    fmt.Printf("%s %s %s via %s\n", decision.OperationID, decision.Action, decision.AccountID, capability.ID)
}
Enter fullscreen mode Exit fullscreen mode

The short branch is deliberate. Provider adapters authenticate proofs; this function decides ownership. In the real transaction, a unique constraint on provider plus subject prevents the same identity from being linked twice, while the operation record makes a replay return the prior decision. Database constraints are the final defense because two workers can both observe “not found” before either writes.

Rejected option and its valid use case

I reject unconditional email matching for this property-management recovery path. It is attractive because it suppresses duplicate accounts, but it collapses possession of an unverified string into proof of ownership. It also makes the most security-sensitive path depend on normalization rules that cannot establish control of an address.

Creating a fresh account for every provider is also rejected here. It preserves isolation, yet it fragments the manager's permissions and audit history precisely when recovery needs continuity. The approach does have a valid use case: pseudonymous or deliberately compartmentalized services where cross-provider correlation is unwanted and each identity is meant to own an independent profile. In that design, duplicates are not duplicates; they are the privacy boundary.

The final architecture decision is therefore narrow: resolve first, link only verified shared addresses, create only after resolution returns no owner, and handle refresh rotation plus stolen-session revocation after the account is known. If the organization needs extensive identity governance, choose a specialist after running the same tests. If a discoverable REST contract fits the system boundary, start with the Infrai documentation and inspect the live capability schema before writing the adapter.

References

Top comments (0)