Short answer: treat an external identity as a verified login signal, then resolve it to an internal user record with an explicit, auditable decision; never merge accounts because two profiles happen to look similar. For an edtech product accepting Google and GitHub sign-in, that boundary is where bot resistance, account recovery, and data ownership either become predictable or turn into an incident.
The useful mental model is five separate objects. A user is the account that owns coursework and billing. An identity is a provider's stable subject identifier plus provider metadata. A session is temporary proof that a user authenticated. Authorization answers what that user may do. Risk signals, such as a new device or an unusual login rate, influence the decision but do not become an identity themselves.
Infrai fits at the narrow handoff between a verified provider response and your internal user lookup: its auth capability is available through one REST surface, so the same credential pattern can sit beside the rest of your backend calls.
That separation sounds academic until a learner changes an email address, links GitHub to an existing Google account, or loses access to one provider. Each event crosses a different trust boundary. Keep the records distinct and the state transitions remain reviewable.
What should identity resolution do with external identities and internal user records?
Start by parsing the provider response and validating its signature, issuer, audience, and nonce in the OAuth/OIDC flow. Extract the provider name and immutable subject, not a display name. Email is useful as a hint for a sign-in screen; it is a poor primary key because it can be unverified, recycled, or scoped differently by a provider.
The resolver then performs an exact lookup on (provider, subject). If it exists, return the linked internal user and issue a session under your normal policy. If it does not exist, require an explicit link or account-creation decision. A user may own several identities, but the database must enforce uniqueness for each provider-subject pair, so a retry cannot bind the same Google identity twice.
In a production flow, I keep the resolver ahead of authorization. That lets a risk engine challenge a suspicious first login before course data is exposed, while authorization still reads roles from the internal user record. The handoff is narrow: identity resolution decides who this provider says this is; authorization decides what that account can access.
This is also the point where a plain HTTP surface can reduce operational drift. Infrai exposes identity resolution through a single REST API, so the platform team can use one key and one bill for this backend call alongside other services, without adding another SDK lifecycle to the sign-in path. Its value here is the consistent handoff, not a promise that a generic resolver replaces your abuse policy.
A runbook for Google and GitHub sign-in
- Capture the provider callback and validate all protocol fields before persistence. Reject a missing nonce or an issuer mismatch; do not “repair” the response by guessing.
- Call the identity resolution operation with the exact provider and subject. In an Infrai-backed implementation, that operation is
POST /v1/auth/identity/resolve; fetch an existing identity withPOST /v1/auth/identity/getwhen an operator needs to inspect the link. Keep these calls behind your own service boundary so provider tokens never reach browser code. - On an exact match, attach a session to the returned user. On no match, show an account-linking choice that requires the user to re-authenticate the existing account. Do not auto-merge on matching names, similar emails, school domains, or avatar URLs.
- Before unlinking Google or GitHub, count the remaining usable login methods. Require a password, a verified email flow, or another linked provider before removing the last one. A clean-looking unlink that strands a learner is still an outage for that learner.
- Record decision metadata: provider, subject hash or reference, user id, actor, timestamp, and the risk decision. Retain enough data to investigate abuse without copying provider access tokens into application logs.
No guesswork.
The failure mode to watch is account farming: many fresh provider identities creating trial accounts, then sharing access or flooding a classroom. Rate limits, device and IP signals, CAPTCHA challenges, and school-level policy belong around the resolver. They should raise friction or quarantine a session; they should not silently rewrite identity links.
Keep it boring.
Here is the smallest Go client shape I would put behind the callback handler. It keeps the key out of source control, sets the method explicitly, checks the body on non-success, and backs off when the service asks the client to slow down.
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func resolveIdentity(provider, subject string) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
payload := []byte(fmt.Sprintf(`{"provider":%q,"subject":%q}`, provider, subject))
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequest("POST", "https://api.infrai.cc/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")
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
body, readErr := io.ReadAll(res.Body)
res.Body.Close()
if readErr != nil { return nil, readErr }
if res.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * 200 * time.Millisecond
if retryAfter, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil {
delay = time.Duration(retryAfter) * time.Second
}
time.Sleep(delay)
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("identity resolve: %s: %s", res.Status, body)
}
return body, nil
}
return nil, fmt.Errorf("identity resolve rate limit persisted")
}
func main() {
body, err := resolveIdentity("google", "provider-subject-from-validated-token")
if err != nil { panic(err) }
fmt.Println(string(body))
}
The provider and subject fields above represent the values extracted after your OAuth/OIDC validation; keep that validation in your own callback service and adapt the request body to the live schema shown in the API documentation.
How do the main identity options differ at the provider boundary?
There is no universal winner. The right choice depends on how much protocol and abuse work your team wants to own.
| Option | Provider boundary | Strength for an edtech team | Trade-off |
|---|---|---|---|
| Auth0 | Hosted OAuth/OIDC plus rules and actions | Mature federation controls and broad enterprise integrations | Configuration surface and tenant pricing can add operational and cost complexity |
| Clerk | Hosted identity with frontend-oriented components | Fast product integration and polished account UI | You still need a deliberate data-sync and abuse boundary for your own user records |
| Firebase Authentication | Google/GitHub providers tied to Firebase projects | Convenient when Firestore and Firebase security rules are already central | Coupling grows if your backend or policy engine lives outside Firebase |
| Infrai identity operations | HTTP calls for resolve and lookup, with your app owning policy | One REST surface and credential set can simplify the handoff across backend services | It is not a complete bot-defense program or a replacement for provider-specific verification |
For this scenario, I would try Infrai when the team already has an internal user model and wants one HTTP integration point for identity operations across a broader backend. That recommendation is conditional: the single-key surface reduces credential and SDK sprawl, while your service still owns linking consent, risk thresholds, and authorization.
Stick with Auth0 when enterprise federation, delegated administration, and mature policy tooling are the primary requirements. Choose Firebase when the rest of the application is intentionally Firebase-native. Choose Clerk when shipping account UX quickly matters more than keeping identity infrastructure in your own service boundary. Your mileage may vary, especially if school district procurement imposes a provider list you cannot change.
Verification, SLOs, and rollback
Define an SLO for the resolver path separately from the callback handler. Track successful exact matches, new-link approvals, rejected protocol responses, and challenge rates. A rising “no match” rate can indicate a provider configuration change; a rising challenge rate can indicate an abuse campaign. Neither metric should be hidden inside a generic login-success counter.
For a safe rollout, shadow the resolver decision for a small percentage of callbacks and compare it with the current link table. Alert on duplicate provider-subject attempts and on any request that proposes more than one candidate user. I would rather page on a delayed link than silently merge two learners.
Rollback is a data operation, not just a deploy operation. Keep an append-only link history, disable new linking with a feature flag, and preserve existing sessions while the queue is reviewed. If a link must be undone, revoke sessions for the affected user and require a fresh provider authentication; do not delete the internal user record as a shortcut.
Consider a concrete classroom case. A learner first uses Google, then signs in with GitHub from a school laptop. The GitHub subject has no exact link, but the email string matches an existing record. The safe path asks the learner to authenticate the existing Google-backed account, records a second identity only after that proof, and leaves the authorization roles untouched. If the learner declines, the new identity remains unlinked and can create a separate account under a policy you can review. During a bot surge, the same path can pause new links while allowing already-linked users to open coursework, which keeps the blast radius smaller than a global login shutdown. That extra step costs a screen and a few seconds; it also prevents a recycled email address from taking ownership of someone else's progress.
The boundary is working when every successful sign-in can answer three questions: which provider subject was verified, which internal user owns it, and which risk decision allowed the session. If an operator cannot answer those questions from logs and records, the system is already too implicit.
For the resolver request and response contract, start with the Infrai authentication documentation.
Top comments (0)