The Problem
We wanted users to sign in with multiple OAuth providers (Keycloak, Google, GitHub) using the same email address. By default, NextAuth.js blocks this with an error:
This email is already associated with another account.
Please sign in with the provider you used previously.
The error code is OAuthAccountNotLinked.
Why NextAuth Blocks This
It's a security feature. If someone creates an unverified account with your email on a provider that doesn't verify emails, they could potentially access your account.
The Solution
NextAuth.js has an option called allowDangerousEmailAccountLinking that allows this behavior when you trust your OAuth providers.
What Didn't Work
Attempt 1: Global Configuration
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
allowDangerousEmailAccountLinking: true, // Didn't work
providers: [...]
}
TypeScript removed this property because it's not in the type definition.
Attempt 2: Route Handler
const handler = NextAuth({
...authOptions,
allowDangerousEmailAccountLinking: true, // Still didn't work
});
The setting wasn't being recognized at runtime.
What Actually Worked
The key is to set allowDangerousEmailAccountLinking at the provider level:
export const authOptions = {
adapter: PrismaAdapter(prisma),
providers: [
KeycloakProvider({
clientId: process.env.KEYCLOAK_CLIENT_ID || "",
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || "",
issuer: process.env.KEYCLOAK_ISSUER || "",
allowDangerousEmailAccountLinking: true, // This works!
}),
],
} as any; // Use 'as any' to bypass TypeScript checking
Extending the Type Definition
To make TypeScript happy, add this type extension:
declare module "next-auth" {
interface NextAuthOptions {
allowDangerousEmailAccountLinking?: boolean;
}
}
Custom Adapter for Better Account Linking
We also created a custom adapter to handle account linking more gracefully:
const customAdapter = {
...PrismaAdapter(prisma),
async getUserByEmail(email: string) {
return await prisma.user.findUnique({
where: { email },
include: { accounts: true },
});
},
async linkAccount(account: any) {
try {
// Check if account already exists
const existing = await prisma.account.findUnique({
where: {
provider_providerAccountId: {
provider: account.provider,
providerAccountId: account.providerAccountId,
},
},
});
if (existing) {
// Update existing account
return await prisma.account.update({
where: { id: existing.id },
data: {
access_token: account.access_token,
refresh_token: account.refresh_token,
expires_at: account.expires_at,
},
});
}
// Create new account
return await prisma.account.create({
data: {
userId: account.userId,
type: account.type,
provider: account.provider,
providerAccountId: account.providerAccountId,
access_token: account.access_token,
refresh_token: account.refresh_token,
expires_at: account.expires_at,
},
});
} catch (error: any) {
// Handle unique constraint violations
if (error.code === 'P2002') {
const existing = await prisma.account.findUnique({
where: {
provider_providerAccountId: {
provider: account.provider,
providerAccountId: account.providerAccountId,
},
},
});
if (existing) {
return await prisma.account.update({
where: { id: existing.id },
data: {
userId: account.userId,
access_token: account.access_token,
refresh_token: account.refresh_token,
expires_at: account.expires_at,
},
});
}
}
throw error;
}
},
} as Adapter;
Dependencies Issue
We also encountered a compatibility issue with Prisma v7. The solution was to update the adapter:
npm install @auth/prisma-adapter@latest
npx prisma generate
This updated from version 2.8.0 to 2.11.1, which fixed module resolution errors.
When Is This Safe?
This approach is safe when:
- You're using OAuth providers that verify email ownership (Google, GitHub, Keycloak with verification)
- You have database constraints to prevent duplicate accounts
- You log account linking events for auditing
Complete Working Example
// src/lib/next-auth-options.ts
import { PrismaAdapter } from "@auth/prisma-adapter";
import KeycloakProvider from "next-auth/providers/keycloak";
import { prisma } from "@/lib/prisma";
export const authOptions = {
debug: process.env.NEXTAUTH_DEBUG === 'true',
adapter: PrismaAdapter(prisma),
allowDangerousEmailAccountLinking: true,
providers: [
KeycloakProvider({
clientId: process.env.KEYCLOAK_CLIENT_ID || "",
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || "",
issuer: process.env.KEYCLOAK_ISSUER || "",
allowDangerousEmailAccountLinking: true,
}),
],
callbacks: {
async signIn({ user, account }) {
if (process.env.NEXTAUTH_DEBUG === 'true') {
console.log("[Auth] Sign in:", {
email: user.email,
provider: account?.provider
});
}
return true;
},
},
events: {
async linkAccount(message) {
console.log("[Auth] Account linked:", {
provider: message.account?.provider,
userId: message.user?.id
});
},
},
pages: {
signIn: "/auth/login",
error: "/auth/error",
},
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60,
},
} as any;
declare module "next-auth" {
interface NextAuthOptions {
allowDangerousEmailAccountLinking?: boolean;
}
}
Testing
- Sign in with one provider (e.g., Google)
- Sign out
- Sign in with a different provider (e.g., Keycloak) using the same email
- Check your database - both accounts should be linked to the same user
Summary
The fix requires two changes:
- Add
allowDangerousEmailAccountLinking: trueto each provider configuration - Use
as anyfor the authOptions export to preserve the property at runtime
This enables users to sign in with multiple OAuth providers using the same email address.
For more details, see our changelog.