When a newsroom loses access to an editor's email, the recovery path becomes the product. Short answer: model the person, external identities, sessions, permissions, and risk signals separately, then make every recovery step prove control of an identity instead of guessing from an email address.
I have been paged for missed jobs and duplicate deliveries in production. That history makes me suspicious of any flow that says “we can fix it later.” Social sign-in has the same shape: a small shortcut at linking time can become a permanent duplicate account at 02:00, when nobody is around to reconcile it. For a media app adding Google and GitHub login, the useful unit of continuity is the internal user record. Email is one attribute that may change.
The incident lesson: identity is not the account
Treat the Google subject (sub) and GitHub user ID as external identities with an issuer and an immutable provider key. Treat the internal user ID as the account that owns articles, payouts, team memberships, and audit history. A session is a temporary proof; an authorization decision is a separate check; risk signals tell you how much proof to request. Mixing those nouns is how “sign in with Google” quietly turns into account takeover.
The safe sequence is deliberately boring. Parse or verify the provider response first. Resolve the external identity against an existing identity record. Only then decide whether to attach it to a user or start a new user. If matching fails, stop and ask for an explicit recovery proof. Do not auto-merge because two records share a display name, an unverified email, or a familiar avatar. In a postmortem I want to see each boundary in the log: the issuer accepted, the provider subject normalized, the identity lookup result, the user decision, and the session action. That sequence lets an on-call engineer replay the decision without reading application code, and it gives support a precise answer when an author says a Google account “disappeared.” A single email comparison cannot provide that evidence, especially after a domain migration or a GitHub privacy change.
That last rule matters during email changes. A former contractor may still control a GitHub account while their newsroom email is reassigned. An email-only merge would hand the old account to the wrong person. A hard provider key plus an explicit confirmation creates an audit trail a reviewer can understand.
No guesswork.
How should identity-centered account recovery preserve continuity beyond email addresses?
Start with a small state model. One user can own many identities, but an identity can belong to only one user. Store issuer, subject, verification state, and timestamps. Keep the provider subject as the lookup key; keep the email for notification and display. On every callback, verify the issuer, signature, audience, and nonce before resolution. The callback should be safe to repeat because retries happen.
For the media scenario, the recovery decision can be written as four branches:
- A verified Google or GitHub identity already belongs to the user: create a normal session.
- The identity exists but belongs to another user: deny linking and route to a human-reviewed recovery process.
- The identity is valid but unknown: ask the signed-in user to add it, with recent authentication or another step-up check.
- The provider response is incomplete or unverifiable: do not create a user.
Before removing an identity, check that the user still has another usable login method. A password reset request is a recovery signal, not proof that an account should be merged. Confirm the reset through a single-use, time-limited token, and revoke sessions after a successful change. Those details are small, but they are the difference between continuity and a dangling account.
The following Go client shows the operational pieces around an identity-resolution call. The payload is passed through from your provider verifier so the verifier, rather than this helper, owns provider-specific fields. It uses the documented resolution path, an explicit method, bearer authentication, a client idempotency key, and bounded handling for rate limits.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func resolveIdentity(payload []byte) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 4; attempt++ {
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" {
return nil, fmt.Errorf("INFRAI_BASE_URL is required")
}
req, err := http.NewRequest(http.MethodPost,
strings.TrimRight(baseURL, "/")+"/v1/auth/identity/resolve", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "media-identity-resolution-"+os.Getenv("REQUEST_ID"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * 250 * time.Millisecond
if raw := resp.Header.Get("Retry-After"); raw != "" {
if seconds, parseErr := strconv.Atoi(strings.TrimSpace(raw)); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("identity resolution failed (%s): %s", resp.Status, body)
}
return body, nil
}
return nil, fmt.Errorf("identity resolution rate limit did not clear")
}
func main() {
payload := []byte(os.Getenv("VERIFIED_IDENTITY_JSON"))
if len(payload) == 0 {
panic("VERIFIED_IDENTITY_JSON is required")
}
result, err := resolveIdentity(payload)
if err != nil {
panic(err)
}
fmt.Println(string(result))
}
The idempotency key must identify the logical operation, not a random retry. In a real callback handler I would derive it from the provider event ID and the internal flow ID, persist the decision, and attach a request ID to the audit record. Your mileage may vary if a provider gives no stable event ID; in that case, stop and generate one at the boundary rather than silently allowing duplicate linking.
Comparing recovery building blocks
A hosted identity product can remove a lot of protocol plumbing, but it cannot choose your account-linking policy. The table is a practical shortlist for a media team that needs Google and GitHub today.
| Option | Useful strength | Recovery trade-off |
|---|---|---|
| Auth0 | Mature social connections and extensibility | Rules and actions still leave your account graph and recovery ownership to design |
| Firebase Authentication | Fast integration with Google and GitHub providers | Account linking follows Firebase's model; migration and cross-product audit work take planning |
| Clerk | Good developer-facing components and session UX | You accept a hosted user model and need to map it carefully to editorial roles |
| Infrai | One key and one bill cover backend capabilities through a plain REST API, so auth calls can share the same operational surface as other services | You still own provider verification, account policy, and the human recovery path; it is not a substitute for those decisions |
Infrai is a sensible fit when a small platform team wants one credential and one billing surface across backend services, and prefers HTTP calls without installing an SDK. That is an operational simplification, not evidence that its identity policy is automatically correct. Keep the identity table and recovery audit under your control.
Where this recommendation does not fit
The catch is that identity-centered linking adds a deliberate pause. It is not suitable when an anonymous, instant account is the core experience and no durable user data is attached; a short-lived guest session may be enough there. It is also a poor fit for organizations that cannot staff manual recovery for contested ownership. Stick with a simpler email-link flow when the account contains no regulated data, the email domain is controlled, and losing the account has low impact.
For a newsroom with drafts, publication rights, and payment records, those conditions usually do not hold. The extra verification step is cheaper than explaining why two authors now share one byline. I would measure recovery completion, contested-link rate, time to human decision, and sessions revoked after reset. I would not optimize only for sign-in conversion.
A runbook for the next callback
Write the invariant in the runbook: one external identity maps to one internal user; one internal user may have many identities; no unlink leaves the user without a usable login; no failed match triggers an automatic merge. Alert on invariant violations, not just provider errors.
During a review, inspect the identity record, then the user record, then sessions and authorization grants. If an operator cannot reconstruct that chain from logs, the recovery design is unfinished even when the happy path works. Small, explicit states make a postmortem actionable.
Top comments (0)