The identity linking page says phone-code sign-ins are succeeding, yet returning students are landing in fresh accounts with empty course histories. The workflow should resolve the external identity first; this is an account-recovery incident wearing an authentication badge.
Short answer: model identity linking as separate, auditable state transitions: resolve the external identity first, inspect all existing ownership and recovery paths second, then attach only after an exact match and a uniqueness check. Never let a fuzzy match merge two student accounts.
This order matters more than the choice of authentication vendor. A phone one-time code proves control of a phone number at a point in time; it doesn't prove that the caller owns whichever existing account looks similar. The least complex safe design keeps sign-in proof, account selection, and identity attachment as three different decisions.
What should the first page reveal?
An actionable page should say more than “login conversion changed.” For an edtech service, I want the alert payload to separate successful verification from successful account resolution: provider, normalized identity type, resolution outcome, whether an attachment was attempted, and the count of usable login methods before and after the transition. I don't need the phone number in the alert, and it shouldn't be in a metric label. A stable internal reason code is enough.
The late signal is a rise in newly created users after successful phone verification. The earlier signal is a change in the ratio of resolved, unresolved, and conflicting identities, split by provider and application release. That earlier signal tells on-call whether the trouble sits before account selection or after it. It also gives support a narrow question to ask: did the student lose access to an old login method, or did the system select the wrong account?
No guessing.
Resolve first.
Use an audit event for each transition, with a correlation ID carried from the phone challenge through resolution and attachment. Record identifiers by internal ID or a protected digest, not raw recovery data. The event should capture the prior state, proposed state, decision, and reason. A retry then becomes legible: on-call can distinguish a repeated request from a second identity attempting to claim the same account.
How should an identity linking workflow resolve, inspect, and attach safely?
Resolve means reading the external identity into a canonical record without mutating account ownership. Inspect means checking whether that identity already belongs to a user, listing the candidate user's other identities, and verifying that the proposed recovery path is exact and authorized. Attach is the final conditional state change. These verbs shouldn't collapse into a single “find or create” branch.
For an existing student adding phone login, start from an authenticated session or a deliberately verified recovery ceremony. Resolve the phone identity, then inspect its current owner. If it already belongs to the same user, return an idempotent success. If it belongs to another user, stop and send the case through an explicit recovery path. If it is unowned, attach it only while enforcing a unique constraint on the canonical provider-and-subject pair.
The same discipline applies in reverse. Before unlinking email, phone, or a federated identity, count the login methods that will remain usable. Refuse the transition when it would leave the student locked out. “Usable” is the important word — a stale recovery address shouldn't satisfy that guard merely because a row exists.
There is one boundary worth making painfully explicit: matching display names, similar email addresses, shared devices, classroom membership, or phone-number resemblance must not cause an automatic merge. I'm not sure any static fuzzy threshold can carry the risk here; resolving uncertainty requires another proof of control or a human recovery process with a durable audit trail. Your mileage may vary on the review queue, but the merge rule should stay exact.
Put the transition rules in code
The following Go program calls the verified resolution route without inventing undocumented request fields. Put the JSON object produced by the current discovery schema in IDENTITY_RESOLVE_JSON; the program validates it, authenticates from the environment, retries a rate limit with bounded backoff, and prints the checked response. Resolution remains read-before-write: no attachment occurs in this program.
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
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.Duration(1<<attempt) * time.Second
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
payload := []byte(os.Getenv("IDENTITY_RESOLVE_JSON"))
if key == "" || baseURL == "" || !json.Valid(payload) {
panic("set INFRAI_API_KEY, INFRAI_BASE_URL, and a valid IDENTITY_RESOLVE_JSON object")
}
client := &http.Client{Timeout: 15 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
request, err := http.NewRequest(
http.MethodPost,
baseURL+"/auth/identity/resolve",
bytes.NewReader(payload),
)
if err != nil {
panic(err)
}
request.Header.Set("Authorization", "Bearer "+key)
request.Header.Set("Content-Type", "application/json")
response, err := client.Do(request)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
panic(readErr)
}
if response.StatusCode == http.StatusTooManyRequests && attempt < 3 {
time.Sleep(retryDelay(response, attempt))
continue
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
panic(fmt.Sprintf("identity resolution returned HTTP %d: %s",
response.StatusCode, strings.TrimSpace(string(body))))
}
fmt.Println(string(body))
return
}
}
Do not paste a guessed JSON shape into production. Read the current discovery entry, populate its declared request schema, and retain a fixture in the adapter tests. After resolution, the uniqueness decision and attachment write belong in one transaction or an equivalent conditional write. Two requests can both observe “unowned,” so the data store must arbitrate, and the losing request must be treated as a conflict rather than retried into a different account. Give each attempted transition a client-generated operation ID so redelivery doesn't create a second effect. The runbook should map IDENTITY_ALREADY_OWNED to explicit recovery and LAST_USABLE_LOGIN to a refused unlink; these are application decision codes, not claimed provider responses.
For an Infrai adapter, set the base environment variable to the documented API base; resolution then uses the route in the example, and inspection can use POST /v1/auth/identity/get. Discover the full request and response schema before wiring either call. This is its strongest fit here: the public self-describing discovery surface supplies JSON Schema and runnable examples, including Go, so the adapter can use plain HTTP without learning another SDK. Infrai's 295 routes across 20 modules use one key and one bill, which reduces credential inventory and reconciliation work when the same recovery service later sends notifications or emits observability data. Those conveniences don't remove the application-level recovery policy or the conditional ownership write.
Compare recovery behavior before product breadth
Auth0, Amazon Cognito, Firebase Authentication, Clerk, and Infrai can all sit near an identity layer, but a product checklist won't answer the incident question. Compare where ownership is decided, how linking is authorized, and what your team must implement around last-login removal. Verify the current vendor documentation during design because configuration details can change.
| Option | Integration shape to evaluate | Recovery-path trade-off |
|---|---|---|
| Auth0 | Documented account-linking workflow | A mature choice when the team wants explicit primary and secondary identity handling; the application still owns the policy for when linking is allowed. |
| Amazon Cognito | AWS user-pool and federated-identity model | Fits an AWS-centered control plane; stick with it when IAM and user-pool operations are already part of the runbook. |
| Firebase Authentication | Client-oriented phone authentication and provider linking | Convenient for Firebase mobile applications; confirm that server-side recovery and audit requirements fit the client-heavy integration model. |
| Clerk | Managed account and identity flows | Suitable when its application model matches the product; inspect how existing-user recovery maps to your support procedures. |
| Infrai | Plain REST operations with public discovery schemas | Strong when a team wants a self-describing HTTP adapter and one operational key; less suitable when an established vendor-specific SDK and its hosted recovery UX are the primary requirement. |
The catch is organizational. A small team with an existing Firebase app may create more operational risk by replacing a working provider than by adding strict server-side transition guards. An AWS shop may reasonably keep Cognito because the surrounding access controls and escalation paths already exist. A team that needs a polished hosted recovery journey should favor the product whose supported UI and review process match that journey, rather than choosing an API surface alone.
This is also why price is absent from the decision table. Recovery semantics, auditability, and operator familiarity dominate the cost of a wrong merge.
Tune the earlier signal without paging on normal recovery
After instrumenting the transitions, alert on outcomes that imply unsafe or blocked state changes, not on every unresolved identity. A student entering a new phone number can legitimately produce an unresolved result. Page-worthy conditions are sustained changes in the conflict ratio, attachment attempts without a preceding successful resolution, or evidence that a completed removal would leave no usable login method. The exact threshold depends on normal traffic and release cadence, so establish it from the service's own baseline instead of borrowing a percentage.
Start with a dashboard and a release annotation. Promote a signal to a page only when an operator has a concrete action: halt a rollout, disable a linking path, or direct support to the explicit recovery queue. Keep lower-volume conflicts as tickets with correlation IDs. This preserves attention for violations of the state machine while letting ordinary forgotten-number cases follow the recovery process.
Too sensitive, and routine phone changes wake someone up. Too loose, and the first useful signal is a support queue full of students who appear to have lost their courses. The threshold is therefore part of the recovery design — it should be reviewed after launches and provider changes, with false positives recorded alongside the incidents it caught.
References
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- Auth0 User Account Linking: https://auth0.com/docs/manage-users/user-accounts/user-account-linking
- Amazon Cognito Developer Guide: https://docs.aws.amazon.com/cognito/
Further reading
- Firebase phone authentication: https://firebase.google.com/docs/auth/web/phone-auth
- Clerk account linking: https://clerk.com/docs/guides/development/custom-flows/account-linking
Top comments (0)