Auth.js auto-link OAuth on verified email

activetype/resourcetech/authtech/oauth

Auth.js auto-link OAuth on verified email

TL;DR

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.

When to use

  • You have both a Credentials provider and at least one OAuth provider, or multiple OAuth providers.
  • You want users who originally signed up via email/password to be able to "Continue with Google" with the same email and land in the same account — bank data, settings, transactions, all intact.
  • Your OAuth provider returns a verified-email signal you trust (Google's email_verified boolean works; some providers don't).
  • You're OK shipping this without an explicit "Link your accounts" settings UI in v1 — auto-link covers the natural flow.

When NOT to use

  • Your OAuth provider doesn't verify emails (e.g. GitHub email can be a self-asserted secondary). Auto-link there would let an attacker register victim@example.com on GitHub and take over the victim's Credentials account.
  • You need explicit user consent before linking (some products treat account-linking as a high-trust action requiring a confirmation step).
  • Your email isn't normalised case-insensitively across paths. Auto-link assumes victim@example.com and Victim@example.com resolve to the same user record.

Sketch

Provider config

// 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,
      // …
    };
  },
}),

signIn callback guard

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;
}

Adapter requirement

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.

Trade-offs

  • Loses the "block and warn" UX. A user with 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.
  • Account takeover model: an attacker who controls 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.
  • No unlink UI in v1. Once linked, the user has two 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.
  • Defense in depth: the 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.

Origins

  • [[Projects/personal-finance-notion/context/auth-as-built-2026-05-19]] — production use as of Phase B 2026-05-19; ADR'd in [[Projects/personal-finance-notion/decisions/adr-2026-05-18-authjs-migration|ADR 2026-05-18]] (revised from earlier "block" policy)

Pitfalls

  • The "dangerous" name is misleading. It refers to the generic case (any OAuth provider, any email). With Google personal accounts + an email_verified guard, it's the standard pattern, not a footgun.
  • Workspace accounts with custom verification policy. Google's 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.
  • Email casing in 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.
  • Don't auto-link unverified providers. If you add GitHub or another provider where email verification isn't guaranteed, set allowDangerousEmailAccountLinking: false for that provider specifically (provider configs are independent — you don't have to apply the same setting to all).
  • Ordering matters for the signIn callback. Auth.js runs the callback before the adapter persists anything. If you return a string from signIn, the user never lands in the DB — no orphan accounts row, no half-created user.

See also

  • [[Resources/Tech/Auth.js/Auth.js custom mongoose adapter pattern]] — supplies the getUserByEmail and linkAccount this pattern depends on
  • [[Resources/Tech/Auth.js/Auth.js Next.js JWT Google MongoDB adapter pattern]] — base setup