DEV Community

NyxenL29
NyxenL29

Posted on

Identity Resolution: Mapping External Sign-Ins to Internal User Records

Short answer: keep the provider identity as an immutable login identifier, keep your own user record as the product identity, and connect them through a separately constrained mapping table. For an edtech app accepting Google and GitHub sign-in, resolve that mapping only after validating the issuer, audience, signature, and token claims. Never use an email string as the primary key.

The failure signal is an identity collision

Identity resolution sounds like a lookup. In production it is a boundary between two naming systems, each with a different owner and lifecycle. Google can change a display name; GitHub can return an email that is private or absent. Your application still needs one stable account for course progress, organization membership, audit history, and deletion requests.

The dangerous shortcut is users.email = token.email. It makes account takeover and accidental account merging possible when an address is unverified, recycled, or represented differently by two providers. A second shortcut is treating a provider's numeric subject as globally unique. It is only unique within its issuer and client configuration, so the pair (issuer, subject) belongs in the key.

Keep it boring.

I plan capacity around this lookup because it sits on every login, but the SLO is about correctness as much as latency: a 200 ms response that attaches a learner to the wrong school is still an outage. Track resolution outcomes (created, linked, rejected, needs_review) and alert on a sudden rise in rejections or new links per minute. Those signals catch configuration drift before support tickets do.

How do external identities resolve into internal user records?

Use three records with intentionally boring fields:

Record Owns Stable key
users Product profile, status, tenant, consent state Internal UUID
external_identities Provider relationship and last-seen claims (issuer, subject)
login_attempts Security events and decision reason Request ID

The mapping table should have a unique constraint on (issuer, subject), a foreign key to users, and an explicit verified_at or verification state. Store the issuer URL you validated, not a user-supplied label. Keep raw tokens out of the database; retain only the claims required for authorization, troubleshooting, and account recovery.

The request path is a small state machine. Validate the signed ID token against the provider's published keys, check iss, aud, exp, and the nonce, then derive (iss, sub). Look up the mapping. If it exists, load the internal user and apply account status and tenant policy. If it does not, create a pending internal user or send the person through an explicit linking flow. Automatic linking by email should require a separately verified policy and a step-up factor.

Here is a deliberately generic Go boundary. The provider adapters can use an OIDC library, but the resolver does not need to know which provider supplied the claims.

package identity

import (
    "context"
    "errors"
)

var ErrNeedsLinking = errors.New("identity needs explicit linking")

type Claims struct {
    Issuer        string
    Subject       string
    Email         string
    EmailVerified bool
}

type IdentityStore interface {
    FindByExternal(ctx context.Context, issuer, subject string) (string, error)
    CreatePendingUser(ctx context.Context, c Claims) (string, error)
}

func Resolve(ctx context.Context, store IdentityStore, c Claims) (string, error) {
    if c.Issuer == "" || c.Subject == "" {
        return "", errors.New("missing validated identity claims")
    }
    if id, err := store.FindByExternal(ctx, c.Issuer, c.Subject); err == nil {
        return id, nil
    }
    if c.Email == "" || !c.EmailVerified {
        return "", ErrNeedsLinking
    }
    return store.CreatePendingUser(ctx, c)
}
Enter fullscreen mode Exit fullscreen mode

The adapter must distinguish “not found” from a datastore failure; the compact example leaves that error taxonomy to the store. A failed database read must fail closed, not silently create a second learner. That distinction deserves a test and a dashboard panel.

What should the sign-in runbook verify before release?

Start with a matrix, then exercise it in a staging tenant that has real policy constraints but synthetic people. Test a first Google login, a first GitHub login with no public email, a repeat login after a display-name change, and an attempted link where the internal account is suspended. Add token tests for a wrong issuer, wrong audience, expired exp, replayed nonce, and an unknown key ID. Then run the same cases through the browser callback and the mobile handoff, because a resolver that is correct in a unit test can still bind the wrong session cookie at the edge. Record the expected decision for every case before the test starts: created must produce one internal UUID, linked must retain the old UUID, and rejected must not write either table. OWASP's authentication guidance is a useful baseline for these checks, while the OpenID Connect specification defines the claim validation contract that your adapter should enforce.

The deployment gate is observable behavior, not a green unit-test badge. Verify that each decision emits a request ID, issuer, outcome, and reason without logging tokens or full email addresses. Keep a counter for links and a histogram for resolution latency. During a key rotation, accept keys from the provider's documented set and make cache refresh bounded; do not disable signature verification to “get through” a rotation.

Rollback is a data operation. Ship the resolver behind a feature flag, stop new automatic links first, and preserve existing mappings while investigating. If a migration added a unique constraint, rehearse the rollback on a copy because deleting duplicate rows can destroy audit evidence. Your recovery objective should cover the mapping table separately from profile data.

Where does this design not fit?

The catch is that explicit linking adds a screen and sometimes a second factor, so it is not suitable when the product demands anonymous, one-click classroom access. In that case, use short-lived guest identities and a later claim flow, with a clear expiration policy. A small internal app with one trusted directory may reasonably use that directory's subject directly, provided the issuer and audience checks remain mandatory.

Buy versus build is a capacity decision, not a badge of engineering taste:

Choice Helps with Costs or limits
Managed identity broker Key rotation, provider adapters, hosted recovery flows Contract lock-in, per-tenant policy fit, another outage dependency
Self-hosted resolver Exact data model, local SLO and audit controls On-call load for keys, abuse defenses, migrations
Small standards-based module Low surface area when providers are stable Your team owns every edge case and response playbook

Stick with a standards-based module when you can staff the on-call rotation and review token changes. Choose a broker when the operational burden is larger than the lock-in you can tolerate. I am not sure any universal threshold exists; your mileage will vary with tenant count, abuse volume, and regulatory retention rules.

References

Top comments (0)