Multiple Providers, One User
The problem
[!NOTE] This lesson replaces the
findOrCreateFromGithubandfindOrCreateFromGooglefunctions you wrote in the previous lessons. Those versions threw an error on email conflicts. The new versions below auto-link instead. When you reach the “Update the database functions” section, replace both functions insrc/db.tsentirely.
Alice signs up using “Log in with GitHub.” Her GitHub account uses the email [email protected]. A week later, she clicks “Log in with Google” on a different device. Her Google account also uses [email protected].
Right now, our code throws an error: “An account with email [email protected] already exists.” Alice is locked out of the Google login because a user with her email already exists from GitHub.
This is a bad experience. Alice is the same person. She should be able to use either provider to log in to the same account.
Account linking
Account linking means connecting multiple OAuth identities to a single local user. Alice’s local account gets both a githubId and a googleId. She can log in with either provider and land on the same account.
The linking logic
When a provider returns a profile with an email that matches an existing user, we have two options:
Option 1: Ask the user to confirm. Redirect to a page that says “An account with this email exists. Log in with GitHub to link your Google account.” This is the most secure approach because it verifies the user controls both providers.
Option 2: Auto-link by verified email. If both providers have verified the email address, automatically link the accounts. This assumes that if GitHub and Google both confirm the same email, it is the same person.
We will implement Option 2 because it is simpler and works well for most applications. The key requirement is that we only trust verified emails from both providers.
Update the database functions
Replace the email conflict errors with linking logic. Update src/db.ts:
export function findOrCreateFromGithub(profile: {
githubId: number;
email: string | null;
name: string | null;
avatarUrl: string | null;
}): { user: User; created: boolean } {
// Case 1: Returning user (by GitHub ID)
const existing = findByGithubId(profile.githubId);
if (existing) {
return { user: existing, created: false };
}
// Case 2: Link to existing account (by verified email)
if (profile.email) {
const emailMatch = findByEmail(profile.email);
if (emailMatch) {
emailMatch.githubId = profile.githubId;
// Update name and avatar if they were null
emailMatch.name ??= profile.name;
emailMatch.avatarUrl ??= profile.avatarUrl;
return { user: emailMatch, created: false };
}
}
// Case 3: New user
const user: User = {
id: crypto.randomUUID(),
email: profile.email,
name: profile.name,
avatarUrl: profile.avatarUrl,
githubId: profile.githubId,
googleId: null,
};
users.set(user.id, user);
return { user, created: true };
}
export function findOrCreateFromGoogle(profile: {
googleId: string;
email: string | null;
name: string | null;
avatarUrl: string | null;
}): { user: User; created: boolean } {
// Case 1: Returning user (by Google ID)
const existing = findByGoogleId(profile.googleId);
if (existing) {
return { user: existing, created: false };
}
// Case 2: Link to existing account (by verified email)
if (profile.email) {
const emailMatch = findByEmail(profile.email);
if (emailMatch) {
emailMatch.googleId = profile.googleId;
emailMatch.name ??= profile.name;
emailMatch.avatarUrl ??= profile.avatarUrl;
return { user: emailMatch, created: false };
}
}
// Case 3: New user
const user: User = {
id: crypto.randomUUID(),
email: profile.email,
name: profile.name,
avatarUrl: profile.avatarUrl,
githubId: null,
googleId: profile.googleId,
};
users.set(user.id, user);
return { user, created: true };
} The change: instead of throwing an error on email match, we link the new provider to the existing account. We set the provider ID (githubId or googleId) on the existing user and return it.
The ??= operator (nullish coalescing assignment) updates name and avatarUrl only if they were null. This means the first provider’s data takes precedence, but missing fields can be filled in by later providers.
Remove the error handling in routes
The callback handlers in src/routes/github.ts and src/routes/google.ts had try/catch blocks for the email conflict error. Since findOrCreateFromGithub and findOrCreateFromGoogle no longer throw on email conflicts, you can simplify:
// Before (with try/catch)
let result;
try {
result = findOrCreateFromGithub({ ... });
} catch (err) {
return Response.json({ error: (err as Error).message }, { status: 409 });
}
// After (no more email conflict errors)
const result = findOrCreateFromGithub({ ... }); The try/catch is no longer needed for normal flow. You might keep it for unexpected errors, but the email conflict case is now handled gracefully.
Try it out
- Log in with GitHub. Visit
/me. Note your user ID and thatgoogleIdisnull. - Log out (clear cookies or use the logout route).
- Log in with Google using the same email address.
- Visit
/me. You should see the same user ID, and now bothgithubIdandgoogleIdare set.
The account was linked automatically because both providers confirmed the same email.
When auto-linking is not safe
Auto-linking by email is convenient but has an edge case. If a provider returns an unverified email, an attacker could:
- Create a GitHub account with
[email protected](without verifying it) - Log in to your app with that GitHub account
- Now they own the local account for
[email protected] - When the real owner tries to log in with Google (which verified the email), the accounts merge, and the attacker has access
This is why we filter for verified emails from both providers:
- GitHub: we use
getPrimaryEmailwhich checks theverifiedflag - Google: emails in ID tokens are verified by default (Google requires email verification)
If you cannot guarantee the email is verified by the provider, do not auto-link. Use Option 1 (ask the user to confirm) instead.
Exercises
Exercise 1: Log in with one provider, then log in with the other using the same email. Verify the /me response shows both provider IDs linked. Log out and log in again with either provider. Verify you reach the same user account both times.
Exercise 2: What happens if you log in with GitHub using one email, then log in with Google using a different email? You should get two separate user accounts. Auto-linking only happens when the emails match.
Why is it important to only auto-link accounts using verified emails?