Social Login Aggregation (Multiple OAuth Providers)
Supporting multiple OAuth providers simultaneously: user selects Google, GitHub, Apple, or Microsoft and logs into a single account. The key question is account linking when different providers use the same email.
NextAuth.js / Auth.js: Multiple Providers
// auth.ts (Auth.js v5)
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
import GitHub from 'next-auth/providers/github';
import Apple from 'next-auth/providers/apple';
import MicrosoftEntraID from 'next-auth/providers/microsoft-entra-id';
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
Apple({
clientId: process.env.APPLE_ID!,
clientSecret: process.env.APPLE_SECRET!, // JWT from .p8 key
}),
MicrosoftEntraID({
clientId: process.env.AZURE_AD_CLIENT_ID!,
clientSecret: process.env.AZURE_AD_CLIENT_SECRET!,
tenantId: process.env.AZURE_AD_TENANT_ID!, // or 'common' for all
}),
],
callbacks: {
async signIn({ user, account, profile }) {
// Auto-link by email
if (user.email) {
const existingUser = await db.user.findUnique({
where: { email: user.email }
});
if (existingUser) {
// Check if this provider is already linked
const existingAccount = await db.account.findFirst({
where: {
userId: existingUser.id,
provider: account!.provider,
}
});
if (!existingAccount) {
// Link new provider to existing account
await db.account.create({
data: {
userId: existingUser.id,
provider: account!.provider,
providerAccountId: account!.providerAccountId,
type: account!.type,
access_token: account!.access_token,
refresh_token: account!.refresh_token,
expires_at: account!.expires_at,
}
});
}
return true;
}
}
return true;
},
async session({ session, token }) {
if (token.sub) {
session.user.id = token.sub;
}
return session;
},
},
adapter: PrismaAdapter(db),
});
Prisma Schema: Linked Accounts
model User {
id String @id @default(cuid())
email String @unique
name String?
image String?
createdAt DateTime @default(now())
accounts Account[]
sessions Session[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
UI: Provider Buttons
// components/SocialLoginButtons.tsx
'use client';
import { signIn } from 'next-auth/react';
const PROVIDERS = [
{
id: 'google',
name: 'Google',
icon: <GoogleIcon />,
className: 'bg-white border border-gray-300 hover:bg-gray-50',
},
{
id: 'github',
name: 'GitHub',
icon: <GitHubIcon />,
className: 'bg-gray-900 text-white hover:bg-gray-800',
},
{
id: 'apple',
name: 'Apple',
icon: <AppleIcon />,
className: 'bg-black text-white hover:bg-gray-900',
},
{
id: 'microsoft-entra-id',
name: 'Microsoft',
icon: <MicrosoftIcon />,
className: 'bg-[#00a4ef] text-white hover:bg-[#0090d4]',
},
] as const;
export function SocialLoginButtons({
callbackUrl = '/',
mode = 'login',
}: {
callbackUrl?: string;
mode?: 'login' | 'register';
}) {
return (
<div className="flex flex-col gap-3">
{PROVIDERS.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => signIn(provider.id, { callbackUrl })}
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg font-medium ${provider.className}`}
>
{provider.icon}
<span>{mode === 'login' ? 'Sign in' : 'Sign up'} with {provider.name}</span>
</button>
))}
</div>
);
}
Clerk: Ready-made Solution
// Clerk is configured via dashboard.clerk.com
// Enable providers: Google, GitHub, Apple, Microsoft
// Account linking — automatic
// Usage
import { SignIn } from '@clerk/nextjs';
export default function LoginPage() {
return (
<SignIn
appearance={{
elements: {
socialButtonsBlockButton: 'border border-gray-200',
}
}}
/>
);
}
Clerk automatically handles conflict resolution when different providers have the same email, showing the user a confirmation dialog.
Managing Linked Accounts
// Settings page: list of linked providers
import { auth } from '@/auth';
import { db } from '@/lib/db';
export default async function SecuritySettingsPage() {
const session = await auth();
const accounts = await db.account.findMany({
where: { userId: session!.user.id }
});
return (
<div>
<h2>Linked Accounts</h2>
{accounts.map(account => (
<div key={account.id} className="flex justify-between">
<span>{account.provider}</span>
{accounts.length > 1 && (
<button onClick={() => unlinkAccount(account.id)}>
Unlink
</button>
)}
</div>
))}
</div>
);
}
Setting up Auth.js with 4 providers, account linking, and Prisma adapter — 2–3 working days.







