One user, three login methods, no duplicate accounts

Infrai's identity endpoints map email, phone and external provider subjects onto a single user. How resolve works, and why auto-linking by email is risky.

Duplicate accounts are an identity-modelling problem, not a login problem. Infrai separates the two: a user record holds the person, and identities — an email address, a phone number, a Google subject — attach to it. POST /v1/auth/identity/resolve is the call that turns “someone just authenticated as X” into “this is user Y”, creating the user only when no identity matches.

Get this right once and the “I already have an account but it won’t let me in” ticket disappears from your inbox.

The three identity types

An identity has a type — email, phone or external — and a value. For external, provider names which one:

curl -sS -X POST "https://api.infrai.cc/v1/auth/identity/get" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"type": "external", "value": "104829173640192837465", "provider": "google"}'
{
  "ok": true,
  "data": {
    "user": {
      "user_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
      "email": "ada@example.com",
      "email_verified": true,
      "name": "Ada",
      "mfa_enabled": false,
      "created_at": "2026-09-03T07:25:21Z",
      "metadata": {}
    },
    "identity": {"type": "external", "value": "104829173640192837465", "provider": "google"},
    "created": false
  }
}

get is the read-only question: does this identity exist, and whose is it? resolve is the same lookup with create available, so it can mint the user and attach the identity in one step.

Note the value for an external provider. It’s the provider’s stable subject id — not the email. Google emails change; Google subject ids don’t.

Resolve, with the linking decision made explicitly

import os

import requests

API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
HEADERS = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}


def resolve_identity(kind: str, value: str, *, provider: str | None = None,
                     email: str | None = None, verified: bool = False,
                     create: bool = True) -> dict:
    """Map an authenticated identity onto a user. `verified` must reflect whether
    the UPSTREAM proved control of the address — never pass True because it looked
    plausible."""
    body = {"type": kind, "value": value, "create": create, "verified": verified}
    if provider:
        body["provider"] = provider
    if email:
        body["email"] = email
    resp = requests.post(f"{API}/v1/auth/identity/resolve", headers=HEADERS, json=body, timeout=15)
    payload = resp.json()
    if not payload.get("ok"):
        raise RuntimeError(payload["error"]["code"])
    return payload["data"]


def after_google_login(subject: str, email: str, email_verified: bool) -> str:
    data = resolve_identity("external", subject, provider="google", email=email,
                            verified=email_verified)
    if data["created"]:
        print(f"new user {data['user']['user_id']}")
    return data["user"]["user_id"]


if __name__ == "__main__":
    print(after_google_login("104829173640192837465", "ada@example.com", True))

created tells you whether this was a first sighting. Branch your onboarding on it, the same way you would on a signup route.

The auto-linking trap

Here’s the decision that deserves a meeting rather than a default.

If a user signs in with Google and you pass their Google email as email with verified: true, the platform can attach that Google identity to the existing user who owns that address. Convenient. It’s also how account takeover works when the provider hasn’t actually verified the address: anyone who can create an account at a provider claiming ada@example.com, and whose provider doesn’t check it, inherits Ada’s account in your product — which is why the honest rule is that verified mirrors the provider’s own email_verified claim and nothing else, and why a provider that doesn’t send one gets verified: false and a confirmation step of your own before anything is linked.

Safer pattern for sensitive products: don’t link during login at all. Create the second identity only from inside an authenticated session, where the user has already proven who they are, and make “connect your Google account” a settings action rather than a login side effect.

Showing and removing linked methods

curl -sS "https://api.infrai.cc/v1/auth/identity/list/au_usr_lMmXGySJeGVM1xicqHGJaJbB" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

items is every identity on that user — that’s your “connected accounts” screen in one call. Removing one is a DELETE with both ids in the path:

curl -sS -X DELETE \
  "https://api.infrai.cc/v1/auth/identity/remove/au_usr_lMmXGySJeGVM1xicqHGJaJbB/au_idt_31fbc9d4e2" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

Guard that in your own code: removing the last identity leaves a user nobody can sign in as. Count first, refuse if it would hit zero, and say why.

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

export async function unlinkIdentity(userId, identityId) {
  const headers = { authorization: `Bearer ${KEY}` };
  const listed = await fetch(`${API}/v1/auth/identity/list/${userId}`, { headers });
  const { data } = await listed.json();
  if ((data.items ?? []).length <= 1) {
    throw new Error("refusing to remove the last sign-in method");
  }
  const res = await fetch(`${API}/v1/auth/identity/remove/${userId}/${identityId}`, {
    method: "DELETE",
    headers,
  });
  if (!res.ok) throw new Error(`unlink failed with ${res.status}`);
  return true;
}
ScenarioCallRisk to watch
Password user adds Googleidentity/resolve from a sessionlink outside login, not during it
Phone-first signup, adds email lateridentity/resolve type emailkeep verified honest
Two accounts already existnot merged for youyou decide which survives
User drops a provideridentity/removedon’t remove the last one

What it doesn’t do

There’s no account-merge endpoint. If a user already has two separate user records — one from a password signup, one from a Google login six months earlier — the platform won’t fold them into one, and the data migration between them is yours. That’s a genuine limitation and the reason to get resolve right before you have a duplicate problem rather than after.

Auth0’s account-linking extension and Stytch both ship more machinery here, including merge flows, and if you’re inheriting a directory that’s already full of duplicates you’d be better off with a tool that has an opinion about merging.

The compensating side is unglamorous and real: when a link succeeds, the confirmation email goes out through POST /v1/email/send and the phone factor through POST /v1/sms/send, both on the key that just did the resolve. No second vendor to onboard for the notification half of the feature.

Every identity route reports billing_class: free in discovery — resolve, get, list and remove aren’t billed per call. Identity is metered per monthly active user, the model Auth0 uses too, so read GET /v1/account/usage for your own accrued figure (verified 2026-09-21). Prices on this platform move downward with vendor contracts, so the live read beats any number in a guide.

References

Browse more auth developer guides