When a user signs up with Google and an existing user already has that email (via Credentials or another OAuth provider), link the new OAuth identity to the existing user rather than refusing. Implemented as allowDangerousEmailAccountLinking: true on the provider + an email_verified guard in the signIn callback. The "dangerous" qualifier is generic; the guard makes it safe for Google personal accounts (which always verify). Workspace accounts with weak verification get bounced.
email_verified boolean works; some providers don't).victim@example.com on GitHub and take over the victim's Credentials account.victim@example.com and Victim@example.com resolve to the same user record.// auth.config.ts (or auth.ts)
Google({
clientId: process.env.AUTH_GOOGLE_ID!,
clientSecret: process.env.AUTH_GOOGLE_SECRET!,
allowDangerousEmailAccountLinking: true,
profile(profile) {
return {
id: profile.sub,
email: profile.email,
name: profile.name,
image: profile.picture ?? null,
emailVerified: profile.email_verified ? new Date() : null,
// …
};
},
}),
async signIn({ account, profile }) {
if (account?.provider === "google") {
const verified = (profile as any)?.email_verified;
if (verified !== true) {
// Refuse with a friendly redirect; auto-link would be unsafe otherwise.
return "/login?error=OAuthEmailNotVerified";
}
}
return true;
}
Auto-link depends on the adapter's getUserByEmail returning a hit when the OAuth email matches an existing user. Make sure your getUserByEmail is case-insensitive (lowercase both sides) — otherwise Google's lowercase email won't match the user's mixed-case stored email and you'll get a duplicate-user surprise.
me@example.com registered via Credentials who clicks "Continue with Google" doesn't see a "this email is already registered — sign in with email instead" message. They just sign in. For many products that's better UX; for some it's confusing.victim@google.com and gets the victim to use that exact email when signing up to your service can hijack — but that requires the attacker to already own the victim's Google account, at which point the victim is in much bigger trouble. Reasonable threat model trade.accounts rows (or however many providers). Adding a "Disconnect Google" button is straightforward (the adapter's unlinkAccount); planning to ship it is a separate decision.profile() mapper sets emailVerified: null when email_verified !== true, AND the signIn callback refuses the same case. Either alone would catch the issue; both catch it twice. Cheap insurance against future edits accidentally removing one guard.email_verified guard, it's the standard pattern, not a footgun.email_verified claim can be false for Workspace tenants whose admin disabled it. The guard catches this; surface the OAuthEmailNotVerified error code on the login page with a helpful message.getUserByEmail: if your DB stores me@example.com but Google sends Me@Example.com, the adapter's findOne({ email }) misses unless you lowercase. Already a footgun in the off-the-shelf @auth/mongodb-adapter; even more important with custom adapters.allowDangerousEmailAccountLinking: false for that provider specifically (provider configs are independent — you don't have to apply the same setting to all).signIn, the user never lands in the DB — no orphan accounts row, no half-created user.getUserByEmail and linkAccount this pattern depends on