The Two Sign-In Flows
Magic-link
1. User submits email on /signin
│
v
2. NextAuth Nodemailer provider runs on the frontend
│
├──► Adapter.createVerificationToken(...)
│ → POST /internal/auth-adapter/verification-tokens
│ (X-Internal-Token; backend stores it)
│
└──► sendVerificationRequest(...) (synchronous)
→ SMTP via Nodemailer (frontend env: EMAIL_SERVER_*)
│
v
3. User clicks the link in the email
│
v
4. NextAuth /api/auth/callback/nodemailer fires
│
├──► Adapter.useVerificationToken({ identifier, token })
│ → POST /internal/auth-adapter/verification-tokens/use
│ (atomic delete + return; backend ensures single use)
│
├──► Adapter.getUserByEmail(email)
│ → GET /internal/auth-adapter/users/by-email/:email
│ → if null: Adapter.createUser(...)
│
└──► Adapter.createSession({ sessionToken, userId, expires })
→ POST /internal/auth-adapter/sessions
│
v
5. NextAuth sets the session cookie (httpOnly, server-only)
OAuth (GitHub / Google)
1. User clicks "Continue with GitHub"
│
v
2. NextAuth redirects to GitHub
│
v
3. GitHub redirects back with auth code
│
v
4. NextAuth exchanges code for tokens, then:
├──► Adapter.getUserByAccount({ provider, providerAccountId })
│ → POST /internal/auth-adapter/users/by-account
│ → found: sign that user in (go to createSession)
│
├──► not found: Adapter.getUserByEmail(email)
│ → found: OAuthAccountNotLinked, no session
│ (linking is off by default; NextAuth redirects to
│ /signin?error=OAuthAccountNotLinked and the form
│ explains it)
│
├──► not found either: Adapter.createUser(...)
│ then Adapter.linkAccount({ userId, provider, ... })
│
└──► Adapter.createSession(...)
│
v
5. NextAuth sets the session cookie
Both flows produce the same end state: a row in sessions with an opaque sessionToken, a row in users for the signed-in person, and (for OAuth) a row in accounts linking the provider identity to the user. The one exception is the same-email collision, described next, which ends in an error instead.
A second sign-in with a different method but the same email (magic link first, then Google, say) takes the OAuthAccountNotLinked branch. The user sees a message on /signin telling them to use the method they signed up with (lib/signin-error.ts).
Opting in to account linking
Linking stays off because it merges into whatever users row has that email. To allow it for Google, set allowDangerousEmailAccountLinking on the provider and refuse unverified emails in a signIn callback (auth.config.ts callbacks survive into auth.ts):
// auth.config.ts
providers.push(Google({ allowDangerousEmailAccountLinking: true }));
// ...
callbacks: {
async signIn({ account, profile }) {
if (account?.provider === 'google') return profile?.email_verified === true;
return true;
},
},
The trade-off: the email match links into any row with that address, including rows created through a provider whose email Auth.js does not verify. The stock GitHub provider ignores GitHub's verified flag, so an attacker could sign in with GitHub as [email protected] and create that row; the victim's later Google sign-in would link into it, and the attacker's GitHub login would then open the victim's account. The template closes that hole for GitHub (see Edge config below), but the same reasoning applies to any provider you add. Only enable linking for providers whose email you trust.