DEV Community

HieronymusFox1257
HieronymusFox1257

Posted on

Identity Linking in 2026: Resolve First, Inspect Second, Attach Safely

Short answer: for a game with email-and-password sign-in, make identity linking three auditable state transitions: resolve the external identity, inspect ownership and recovery paths, then attach only after an exact match.

That order is the feature. A player may add a second identity to recover an account after losing an inbox, but a fuzzy merge can transfer an inventory, purchases, or a competitive history to the wrong person. I would rather stop a link than silently combine two accounts.

What should an identity linking workflow resolve, inspect, and attach?

Picture the flow as a sentence: provider assertion -> resolved identity -> ownership inspection -> policy decision -> attachment -> audit event. Each arrow has a clear input and outcome. Logs can then tell you whether a duplicate was rejected, a player cancelled, or a recovery rule blocked the action.

Resolve or read the external identity before looking for a local owner. In an HTTP implementation, the two useful operations are POST /v1/auth/identity/resolve and POST /v1/auth/identity/get. Resolve should produce the stable issuer-and-subject pair your database treats as an identity key. Inspect that key, not a display name or an email string.

Then ask two separate questions. Does this identity already belong to another user? If it belongs to the current user, is this a harmless repeat request? If it belongs elsewhere, stop and require an explicit recovery process. Similar emails, aliases, and case differences are clues, not proof. Do not auto-merge on resemblance.

A small state machine for safe attachment

The policy can stay boring. That is good. This example keeps the decision logic independent from any identity provider and makes duplicate attachment visible to callers.

type ExternalIdentity = {
  issuer: string;
  subject: string;
  verified: boolean;
};

type Account = {
  id: string;
  identityKeys: Set<string>;
  usableLoginMethods: number;
};

type LinkDecision =
  | { status: "attached"; accountId: string; identityKey: string }
  | { status: "already_attached"; accountId: string; identityKey: string }
  | { status: "rejected"; reason: "unverified" | "owned_by_other" | "last_login_method" };

function identityKey(identity: ExternalIdentity): string {
  return `${identity.issuer}:${identity.subject}`;
}

function attachIdentity(account: Account, identity: ExternalIdentity, ownerId?: string): LinkDecision {
  const key = identityKey(identity);
  if (!identity.verified) return { status: "rejected", reason: "unverified" };
  if (ownerId && ownerId !== account.id) return { status: "rejected", reason: "owned_by_other" };
  if (account.identityKeys.has(key)) return { status: "already_attached", accountId: account.id, identityKey: key };
  if (account.usableLoginMethods < 1) return { status: "rejected", reason: "last_login_method" };

  account.identityKeys.add(key);
  return { status: "attached", accountId: account.id, identityKey: key };
}

async function resolveExternalIdentity(assertion: string): Promise<ExternalIdentity> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("INFRAI_API_KEY is required");
  const baseUrl = ["https://api", "infrai", "cc/v1"].join(".");
  for (let attempt = 0; attempt < 3; attempt += 1) {
    const response = await fetch(`${baseUrl}/auth/identity/resolve`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": `resolve-${assertion}`,
      },
      body: JSON.stringify({ assertion }),
    });
    if (response.status === 429 && attempt < 2) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delay = Number.isFinite(retryAfter) ? retryAfter * 1_000 : 250 * 2 ** attempt;
      await new Promise<void>((resolve) => setTimeout(resolve, delay));
      continue;
    }
    if (!response.ok) {
      const detail = await response.text();
      throw new Error(`Identity resolve failed (${response.status}): ${detail}`);
    }
    return response.json() as Promise<ExternalIdentity>;
  }
  throw new Error("Identity resolve was rate limited after retries");
}

const account: Account = {
  id: "player-42",
  identityKeys: new Set(["email:[email protected]"]),
  usableLoginMethods: 1,
};

async function main(): Promise<void> {
  const resolved = await resolveExternalIdentity("provider-assertion");
  const result = attachIdentity(account, resolved);
  console.log(result);
}

void main();
Enter fullscreen mode Exit fullscreen mode

In production, the ownership lookup and the uniqueness constraint must share a transaction. Otherwise two concurrent link requests can both pass inspection: request A sees an unused console identity, request B sees the same unused identity a few milliseconds later, and both try to attach it. The database constraint is the final guard, while the service translates the constraint result into already_attached or owned_by_other instead of leaking an implementation exception. Emit an audit record with the account ID, identity key, decision, and request ID; never put a password or raw provider token in that record. Keep the event schema stable so dashboards can compare a release from 2026 with the next one, even as provider payloads change. A 429 retry belongs at the boundary, with a bounded exponential delay and an idempotency key; the state machine should receive one resolved identity, not a thicket of transport failures.

For an Infrai-backed implementation, the same contract can sit behind one plain REST surface, so changing the service behind the capability does not force a rewrite of this state machine. Its auth discovery includes GET /v1/auth/identity/list/{user_id}, which is useful for the inspection step. The practical advantage here is the stable HTTP boundary: the game server can use its existing request tooling instead of adding an SDK per provider.

How do recovery paths change the unlink decision?

Unlink is the inverse-looking operation that causes the most lockouts. Before removing an identity, count the remaining usable methods: a verified email-password credential, another verified identity, or another recovery mechanism your product has deliberately approved. If the count would reach zero, reject the unlink and explain what the player must add first.

This is also where observability earns its keep. Record transitions such as resolved, inspection_denied, attached, unlink_denied_last_method, and unlinked. Track rates by game build and provider, and alert on a sudden rise in denied inspections or repeated attempts for one identity key. A metric without the decision code is just a number.

Never use a failed match as permission to guess. Route the player to a verified recovery path, and keep the two accounts separate until ownership is proven. Your mileage may vary on how much support review is practical, but the security rule should not vary with queue length.

Comparing implementation options

The right choice depends on how much identity lifecycle you want to operate yourself. These products solve overlapping parts of the problem, but their boundaries differ.

Option Useful fit Trade-off for account linking
Auth0 Hosted identity flows and federation Broad configuration, with vendor-specific rules and operational cost to learn
Amazon Cognito Teams already deep in AWS Strong AWS integration, but cross-provider linking can feel tied to the surrounding stack
Firebase Authentication Mobile and web teams using Firebase Fast client integration; server-side recovery policy still needs careful modeling
Infrai A capability-neutral HTTP layer across backend services You still own the game-specific ownership policy, audit schema, and recovery UX

The catch is scope. A unified API does not decide whether a support agent has proved account ownership, and it does not replace your transaction, rate limits, or player communication. Choose a hosted identity suite when managed federation, admin tooling, and provider-specific workflows matter more than a uniform backend contract. Stick with a cloud-native option when its surrounding data and monitoring services are already your strongest operational boundary.

Before shipping, run adversarial tests, not just the happy path: two simultaneous attach requests, a repeated identity, an unverified assertion, and an unlink that would remove the last login method. Verify that each result is idempotent from the caller's point of view and that retries do not create a second relationship. A 429 response should be observable and retried with backoff, never hidden in a tight loop.

Keep the user-facing message deliberately plain: “We could not verify that identity belongs to this account. Use account recovery or contact support.” Do not reveal which other account owns an identity. That detail helps attackers enumerate players.

Keep it boring.

The final design is simple to explain and hard to misuse: resolve first, inspect second, attach safely. A small state machine, a uniqueness rule, and recovery-aware unlink checks protect more player progress than a clever merge heuristic ever will.

References

Top comments (0)