Linking Social Network Accounts
Account linking allows a user with an email/password account to add Google or GitHub as a login method and vice versa. The main challenge is safely identifying the account owner before linking.
Architecture: Data Schema
model User {
id String @id @default(cuid())
email String @unique
passwordHash String? // null if only social networks
accounts LinkedAccount[]
}
model LinkedAccount {
id String @id @default(cuid())
userId String
provider String // 'google' | 'github' | 'apple'
providerAccountId String // ID in provider system
providerEmail String? // email from provider
linkedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
@@index([userId])
}
Server Actions: Linking Provider
// app/settings/security/actions.ts
'use server';
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
import { generateState } from '@/lib/oauth';
export async function initiateLinkProvider(provider: string) {
const session = await auth();
if (!session) redirect('/login');
// Generate state with userId (CSRF protection)
const state = await generateState({
userId: session.user.id,
action: 'link',
provider,
});
// Redirect to OAuth flow with link=true parameter
redirect(`/api/auth/link/${provider}?state=${state}`);
}
export async function unlinkProvider(accountId: string) {
const session = await auth();
if (!session) redirect('/login');
// Check that this is current user's account
const account = await db.linkedAccount.findFirst({
where: { id: accountId, userId: session.user.id }
});
if (!account) {
throw new Error('Account not found');
}
// Cannot unlink the only login method
const accountsCount = await db.linkedAccount.count({
where: { userId: session.user.id }
});
const hasPassword = await db.user.findUnique({
where: { id: session.user.id },
select: { passwordHash: true }
});
if (accountsCount <= 1 && !hasPassword?.passwordHash) {
throw new Error('Cannot unlink the only login method');
}
await db.linkedAccount.delete({ where: { id: accountId } });
}
OAuth Callback: Handling Linking
// app/api/auth/callback/[provider]/route.ts
export async function GET(
request: Request,
{ params }: { params: { provider: string } }
) {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
const state = searchParams.get('state');
// Decrypt state
const stateData = await verifyState(state);
if (!stateData || stateData.action !== 'link') {
redirect('/settings/security?error=invalid_state');
}
// Exchange code for access token
const tokens = await exchangeCode(params.provider, code);
const providerUser = await fetchProviderUser(params.provider, tokens.accessToken);
// Check: is this account already linked to another user
const existingLink = await db.linkedAccount.findUnique({
where: {
provider_providerAccountId: {
provider: params.provider,
providerAccountId: providerUser.id,
}
}
});
if (existingLink && existingLink.userId !== stateData.userId) {
redirect('/settings/security?error=account_already_linked');
}
if (!existingLink) {
await db.linkedAccount.create({
data: {
userId: stateData.userId,
provider: params.provider,
providerAccountId: providerUser.id,
providerEmail: providerUser.email,
}
});
}
redirect('/settings/security?success=linked');
}
UI Component for Management
// components/LinkedAccountsManager.tsx
'use client';
import { useState, useTransition } from 'react';
import { initiateLinkProvider, unlinkProvider } from './actions';
const PROVIDERS = [
{ id: 'google', name: 'Google', icon: <GoogleIcon /> },
{ id: 'github', name: 'GitHub', icon: <GitHubIcon /> },
{ id: 'apple', name: 'Apple', icon: <AppleIcon /> },
];
export function LinkedAccountsManager({
linkedAccounts,
hasPassword,
}: {
linkedAccounts: Array<{ id: string; provider: string; providerEmail: string | null }>;
hasPassword: boolean;
}) {
const [isPending, startTransition] = useTransition();
const linkedProviders = new Set(linkedAccounts.map(a => a.provider));
const canUnlink = (provider: string) => {
// Cannot unlink if it's the only login method
const otherAccounts = linkedAccounts.filter(a => a.provider !== provider);
return hasPassword || otherAccounts.length > 0;
};
return (
<div className="space-y-4">
{PROVIDERS.map((provider) => {
const linked = linkedAccounts.find(a => a.provider === provider.id);
return (
<div key={provider.id} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-3">
{provider.icon}
<div>
<p className="font-medium">{provider.name}</p>
{linked && (
<p className="text-sm text-gray-500">{linked.providerEmail}</p>
)}
</div>
</div>
{linked ? (
<button
onClick={() => startTransition(() => unlinkProvider(linked.id))}
disabled={!canUnlink(provider.id) || isPending}
className="text-red-600 disabled:opacity-50"
>
Unlink
</button>
) : (
<button
onClick={() => startTransition(() => initiateLinkProvider(provider.id))}
disabled={isPending}
className="text-blue-600"
>
Link
</button>
)}
</div>
);
})}
</div>
);
}
Implementing provider linking/unlinking with account lock prevention — 2–3 working days.







