DEV Community

PerNilsson3147
PerNilsson3147

Posted on

Progressive Profiling: Updating Verified User Identity Without Account Rebuilds in Fintech

Short answer: keep one internal user record and attach new Google or GitHub identities to it only after a deliberate, authenticated account-linking step. Progressive profiling should add claims to a verified account; it should never create a second identity because a later sign-in uses a different provider.

The identity boundary is the product decision

In a fintech app, a social login is an authentication event, not proof that two email addresses belong to the same person. Google and GitHub can each return a stable subject identifier, but those identifiers live in different namespaces. Email is useful as a hint; it is not a safe merge key by itself.

I model the boundary with three records: users, external_identities, and profile_attributes. The user owns security state and audit history. An external identity stores (issuer, subject) as a unique pair. Profile attributes carry optional data such as company, timezone, or a preferred display name. That split lets a user add information without resetting verification, recovery methods, or transaction limits.

The practical rule is short: discovery may suggest a match, but only an authenticated session can authorize a link. This is the friction-versus-security trade-off. A forced confirmation feels slower, yet an accidental merge can expose invoices, beneficiaries, and support transcripts.

That's the boundary.

How should progressive profiling update a verified user without recreating identity?

Start the link from an already authenticated session. Ask the user to reauthenticate when the account is sensitive or the session is old, then run the provider's authorization-code flow with PKCE. Validate the returned issuer, subject, redirect URI, state, and nonce on the server. Do not accept a browser-posted email as authority.

Here is a small TypeScript service sketch. It deliberately separates an OAuth callback from the link operation; the callback produces a verified external identity, while the link operation enforces ownership and uniqueness.

type ExternalIdentity = { issuer: string; subject: string; userId: string };
type Session = { userId: string; reauthenticatedAt: number };

async function linkIdentity(
  session: Session,
  identity: Pick<ExternalIdentity, "issuer" | "subject">,
  now = Date.now(),
): Promise<void> {
  const maxAgeMs = 10 * 60 * 1000;
  if (now - session.reauthenticatedAt > maxAgeMs) {
    throw new Error("reauthentication_required");
  }

  const existing = await db.externalIdentities.findUnique({
    issuer_subject: { issuer: identity.issuer, subject: identity.subject },
  });
  if (existing && existing.userId !== session.userId) {
    throw new Error("identity_already_linked");
  }
  if (!existing) {
    await db.externalIdentities.create({
      issuer: identity.issuer,
      subject: identity.subject,
      userId: session.userId,
    });
  }
  await audit.log({
    action: "external_identity_linked",
    userId: session.userId,
    issuer: identity.issuer,
  });
}
Enter fullscreen mode Exit fullscreen mode

The database constraint matters more than the happy path. Create a unique index on (issuer, subject) and make the insert transactional. If two tabs race, one loses with a constraint error and the account remains intact. Return a generic message to the browser; logs can retain the precise reason and a correlation ID.

For a solo team, the adapter choice should follow the boundary rather than define it:

Approach Integration shape Best fit Main limitation
Self-hosted OAuth client Standards-based code and PKCE Full control over sessions and data You own patches, key rotation, and abuse controls
Managed identity service SDK or hosted redirect Small team needing a ready admin surface Provider-specific linking and export semantics
Directory or enterprise broker OIDC/SAML federation Workforce or regulated partner access More policy setup and less consumer-friendly UX

The table is a decision aid, not a ranking. Whichever route you use, keep the (issuer, subject) mapping and merge policy in an application-owned layer so a provider migration does not rewrite customer identity.

What can go wrong between Google, GitHub, and a profile form?

The common failure is an email-based auto-merge. A provider may mark an email as unverified, change its primary address, or omit it entirely. Another is treating a display name as identity data. Names collide, and fintech records need a durable subject plus an issuer, not a friendly label.

A subtler failure appears after onboarding: the profile form writes a new row keyed by email while the session is keyed by user ID. The next request sees two rows and quietly drops one set of verification flags. Keep writes scoped to the session's user ID, and version sensitive attributes so a stale form cannot overwrite a newer value.

Provider libraries also have different defaults. Auth0, Firebase Authentication, and Clerk all expose account-linking concepts, but their token lifetimes, reauthentication controls, and metadata models differ. Treat those products as adapters behind your own identity boundary; the security decision stays in your database and policy layer.

I keep a test matrix with at least these cases: same provider twice, Google then GitHub, a subject already linked elsewhere, an unverified email, an expired reauthentication window, and two concurrent link requests. Six cases catch more than a single end-to-end happy path. Your mileage may vary when providers change consent screens, so pin protocol behavior to standards and monitor callback validation failures.

One test deserves extra attention. Imagine a customer who first signs in with Google on a phone, completes a beneficiary review, then later chooses GitHub on a work laptop. The GitHub callback has a different subject and perhaps no email. The server must pause at the link screen, show which existing account is active, and require a fresh authentication before adding the second identity. It must not create a blank profile, copy the review state, or infer ownership from a matching string. If the customer cancels, the original account remains unchanged; if the link succeeds, both subjects resolve to the same user ID and the audit event records the exact issuer. That sequence is longer than the happy path, but it is the path that protects money and reduces support calls.

Shipping criteria for a low-friction, high-trust flow

The UI should explain what will be linked and show the provider name before consent. After success, display the newly attached identity and offer a visible unlink action only when another recovery method remains. Rate-limit link attempts, require CSRF protection on the initiation endpoint, and keep refresh tokens out of browser storage.

Operationally, measure link-start, callback-rejected, link-conflict, and profile-completed events separately. Alert on a spike in rejected state or nonce checks; that is an integration signal, not a reason to weaken validation. Retain an audit record with actor, issuer, subject hash, timestamp, and request ID, while avoiding raw access tokens in logs.

The catch is that progressive profiling is not suitable when your app cannot provide a trustworthy authenticated session or an audit trail. In that case, keep the sign-in friction and require a support-reviewed recovery process. Stick with a new-account flow when the existing record is unverified or legally belongs to a different customer; preserving one ID is valuable only when ownership is clear.

References

Top comments (0)