Short answer: inspect the external identity first, then make account association and credential reset separate, auditable state transitions. In a media product with Google and GitHub sign-in, this keeps a recovery request from silently attaching to the wrong subscriber. It also gives on-call engineers a record they can replay when a user says, “I lost access.”
I have been paged for missed jobs and duplicate deliveries, and the same operational lesson applies here: a successful HTTP response is not proof that the right state transition happened. Recovery needs an explicit subject, an idempotent request, and a human-readable audit trail. Three words matter: inspect, decide, confirm.
How can identity-assisted recovery inspect login methods before a reset?
Treat each authentication action as its own transition. identity_observed means the provider response was parsed and verified. user_selected means it matched exactly one internal user. reset_requested means a reset challenge was issued. reset_confirmed means the challenge was consumed and the new credential was accepted. A failed match is a terminal review state, not permission to guess.
This ordering handles the awkward cases. One user may have both Google and GitHub identities, but a given provider subject must be unique across users. Removing one identity also requires checking that another usable login method remains; otherwise recovery can strand the account. If matching fails, stop and ask for a verified recovery path. Fuzzy email matching is not account recovery.
The API surface can support that narrow flow without turning the article into an endpoint catalog. Read the user’s linked identities with GET /v1/auth/identity/list/{user_id}, create a challenge with POST /v1/auth/password/reset_request, and confirm it with POST /v1/auth/password/reset_confirm. Validate request and response bodies against the published schemas before advancing state.
How should Google and GitHub recovery be associated with a user?
Start with the provider’s verified subject (sub for Google, the stable numeric account id for GitHub), not a display name. Store provider and subject as a unique pair. Email is useful evidence for a support workflow, but it is not a safe automatic merge key: addresses can change, be unverified, or belong to more than one sign-in context.
Here is the decision table I use during design review:
| Situation | Safe transition | Recovery consequence |
|---|---|---|
| Exact provider and subject match | Associate with that user | Offer the known reset route |
| Provider subject is new, email is an exact verified match | Queue explicit linking | Require proof before linking |
| Multiple possible users | No automatic association | Send to support or step-up verification |
| No match | Keep identity unlinked | Offer an independent recovery path |
| Removing an identity | Check remaining login methods | Block removal if it would strand access |
The catch is that a social identity is an account key, not an account-owner proof by itself. Your policy may require a second factor, a recent session, or support review before linking. That is product policy, not something to hide in a callback handler.
A small, idempotent reset worker in Go
The example below shows the write side of the flow. It uses a client-generated idempotency key, explicit methods, bearer authentication, status checks, and bounded 429 backoff. The identity lookup happens before this worker is called.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type resetRequest struct {
UserID string `json:"user_id"`
}
func postJSON(ctx context.Context, path string, body any, idempotencyKey string) ([]byte, error) {
payload, err := json.Marshal(body)
if err != nil { return nil, err }
key := os.Getenv("INFRAI_API_KEY")
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" { return nil, fmt.Errorf("INFRAI_BASE_URL is required") }
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, 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", idempotencyKey)
resp, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil { return nil, readErr }
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * time.Second
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil { delay = time.Duration(seconds) * time.Second }
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("reset request failed (%s): %s", resp.Status, string(data))
}
return data, nil
}
return nil, fmt.Errorf("rate limit retries exhausted")
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
userID := os.Getenv("RECOVERY_USER_ID")
if userID == "" { panic("RECOVERY_USER_ID is required") }
_, err := postJSON(ctx, "/auth/password/reset_request", resetRequest{UserID: userID}, "recovery-"+userID)
if err != nil { panic(err) }
}
A stable request key is tied to the logical operation, so a worker retry does not issue an unbounded set of independent writes. Persist the transition and request id in your own audit store as well. The platform response is evidence; your state machine is the source of workflow truth.
Keep the audit row.
Where the platforms differ
Auth0, Clerk, Firebase Authentication, and a single REST surface can all be valid choices. Their operational shape differs, especially around provider linking, recovery UX, and how much infrastructure your team owns.
| Option | Strength for social sign-in | Recovery trade-off |
|---|---|---|
| Auth0 | Mature federation and enterprise identity features | More configuration and tenant concepts to operate |
| Clerk | Polished user-facing components and session flows | Opinionated UI and data model can constrain custom recovery |
| Firebase Authentication | Tight fit with Firebase apps and client SDKs | Recovery logic is coupled to the Firebase ecosystem |
| Infrai | Broad backend capabilities behind one consistent REST contract | You still own the association policy, audit store, and recovery UX |
Infrai provides one REST API with a consistent interface for auth and adjacent backend capabilities. Its plain HTTP surface works in any language without installing an SDK, and one key can cover the workflow. A provider change therefore does not require rewriting every caller. That reduces integration seams, but it does not decide whether an identity is safe to link. The engineer still has to enforce the exact-match and remaining-login-method rules above.
Stick with Auth0 when enterprise federation and tenant administration dominate the requirements. Choose Clerk when shipping a hosted account UI is the priority. Firebase is a sensible fit when the rest of the application already lives on Firebase. Use a REST-oriented option when your services span languages and you want the same request conventions across backend capabilities.
Test the transitions, not only the happy-path callback. Confirm that a duplicate provider subject is rejected, a multi-identity user can recover through either verified method, and removing the last usable method requires an explicit replacement. Replay the same reset request with the same idempotency key and verify one logical transition in your audit log. These checks belong in the runbook and in integration tests, so an on-call engineer can distinguish a provider mismatch from a missing reset challenge without guessing.
I’m not sure any vendor’s default recovery screen will match a newsroom’s support policy. Your mileage may vary. Make the policy visible in tests and runbooks, then alert on ambiguous matches rather than silently “helping” them.
Top comments (0)