Implement Social Login with Claude Code: Secure Next.js, Auth.js, Google, and GitHub OAuth
Build secure social login with Claude Code, Next.js, Auth.js, Google and GitHub OAuth, including code, review points, and failures.
Social login is not just a shorter registration form. Once you let users enter through Google or GitHub OAuth, you are also designing account identity, account linking, sessions, cookies, CSRF protection, provider scopes, audit trails, and support flows. Asking Claude Code to “add login” without those boundaries often produces a working button but leaves redirect URI mismatches, unsafe email-based linking, excessive scopes, or secrets printed in code.
This guide upgrades the implementation to a production-oriented Next.js App Router and Auth.js setup. Auth.js is the current home for the NextAuth v5 style API, and it already handles the core OAuth state and callback plumbing when you stay on its standard route. In plain terms, the OAuth authorization code is a short-lived exchange ticket, state is a random value used to detect CSRF, the redirect URI is the exact URL where the provider sends the browser back, and account linking means explicitly connecting another provider to the same signed-in user.
Decide the security boundary first
The first decision is not “how many providers do we support?” It is “what does login prove?” For most SaaS and training products, Google and GitHub are enough. Google works well for general users and company identities. GitHub works well for developer tools, technical communities, and workshop registration. Asking for Google Drive, Calendar, or GitHub repo permissions during login is usually a conversion and trust mistake.
Give Claude Code small, reviewable tasks:
- Add Auth.js provider configuration, environment variables, and callback routes.
- Build the login page and protected dashboard page.
- Extend the session with only
user.id, not provider access tokens. - Let signed-in users explicitly link additional providers from account settings.
- Prevent users from removing their last login method.
- Keep client secrets, refresh tokens, and access tokens out of source code, logs, issues, and Markdown drafts.
In Masa’s test project, the biggest time sink was not the generated code. It was a tiny mismatch between the Google Cloud Console redirect URI and the app’s AUTH_URL. Claude Code can create a clean diff, but it cannot magically know what you configured in each external console. Put URLs, scopes, and email-verification rules in a checklist before implementation.
OAuth flow diagram
Google’s own guidance recommends the authorization code flow for better user security. The browser receives a temporary code, then the server exchanges it with the provider. The client secret never belongs in browser code.
sequenceDiagram
participant User as User
participant App as Next.js app
participant Auth as Auth.js route
participant Provider as Google or GitHub
User->>App: Click "Continue with Google"
App->>Auth: signIn("google")
Auth->>Provider: Redirect with client_id, redirect_uri, scope, state
Provider->>User: Consent screen
Provider->>Auth: Redirect back with code and state
Auth->>Provider: Exchange code on the server
Provider->>Auth: Return tokens
Auth->>App: Create session cookie
App->>User: Show dashboard
state is the value that proves the callback belongs to the login request you started. Auth.js handles this for standard provider routes. If you build a custom callback, never skip state validation. GitHub’s OAuth documentation also recommends an unguessable state value and aborting the flow if the returned state does not match.
Implementation prerequisites
The following setup uses Next.js App Router, TypeScript, Auth.js, and Prisma Adapter. The provider scopes are intentionally small: Google gets openid email profile, and GitHub gets read:user user:email. Do not request repository or Google API scopes until the user enters a feature that truly needs them.
npm install next-auth@beta @auth/prisma-adapter prisma @prisma/client
npx prisma init
npm exec auth secret
# .env.local
AUTH_SECRET="value generated by npm exec auth secret"
AUTH_URL="http://localhost:3000"
AUTH_GOOGLE_ID="client ID from Google Cloud Console"
AUTH_GOOGLE_SECRET="client secret from Google Cloud Console"
AUTH_GITHUB_ID="client ID from GitHub OAuth App"
AUTH_GITHUB_SECRET="client secret from GitHub OAuth App"
DATABASE_URL="postgresql://user:password@localhost:5432/app"
Keep actual secrets only in environment variables or a secret manager. For documentation and pull requests, use an empty example file.
# .env.example
AUTH_SECRET=
AUTH_URL=
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=
DATABASE_URL=
For Google, add http://localhost:3000/api/auth/callback/google in development and https://example.com/api/auth/callback/google in production. For GitHub, use https://example.com/api/auth/callback/github. The provider suffix must match.
Prisma schema
The Account table is what makes account linking auditable. It stores the provider and providerAccountId connected to a user.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
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
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma?: PrismaClient;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
Auth.js configuration
This configuration rejects unverified Google email addresses and GitHub accounts that do not return an email. It also keeps provider tokens out of the browser session.
// auth.ts
import NextAuth, { type NextAuthConfig } from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
type GoogleProfile = {
sub: string;
name?: string;
email: string;
email_verified: boolean;
picture?: string;
};
export const authConfig = {
adapter: PrismaAdapter(prisma),
session: { strategy: "database" },
providers: [
Google({
authorization: {
params: {
scope: "openid email profile",
response_type: "code",
},
},
profile(profile: GoogleProfile) {
return {
id: profile.sub,
name: profile.name,
email: profile.email,
image: profile.picture,
emailVerified: profile.email_verified ? new Date() : null,
};
},
}),
GitHub({
authorization: {
params: {
scope: "read:user user:email",
},
},
}),
],
callbacks: {
async signIn({ account, profile, user }) {
if (account?.provider === "google") {
const googleProfile = profile as GoogleProfile | undefined;
return Boolean(googleProfile?.email && googleProfile.email_verified);
}
if (account?.provider === "github") {
return Boolean(user.email);
}
return true;
},
async session({ session, user }) {
session.user.id = user.id;
return session;
},
},
pages: {
signIn: "/login",
error: "/login",
},
} satisfies NextAuthConfig;
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
// src/types/next-auth.d.ts
import "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
name?: string | null;
email?: string | null;
image?: string | null;
};
}
}
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
Login page and protected page
Calling signIn from a Server Action keeps the app on the Auth.js route, including its CSRF and state handling.
// src/app/login/page.tsx
import { signIn } from "@/auth";
const providers = [
{ id: "google", label: "Continue with Google" },
{ id: "github", label: "Continue with GitHub" },
] as const;
export default function LoginPage({
searchParams,
}: {
searchParams: { error?: string };
}) {
return (
<main className="mx-auto flex min-h-screen max-w-sm flex-col justify-center gap-6 px-6">
<div>
<h1 className="text-2xl font-bold">Sign in</h1>
<p className="mt-2 text-sm text-gray-600">
Choose the work identity you want to use for this application.
</p>
</div>
{searchParams.error ? (
<p className="rounded-md bg-red-50 p-3 text-sm text-red-700">
Sign-in failed. Try another account or contact support.
</p>
) : null}
<div className="grid gap-3">
{providers.map((provider) => (
<form
key={provider.id}
action={async () => {
"use server";
await signIn(provider.id, { redirectTo: "/dashboard" });
}}
>
<button
type="submit"
className="w-full rounded-md border px-4 py-3 text-sm font-medium hover:bg-gray-50"
>
{provider.label}
</button>
</form>
))}
</div>
</main>
);
}
// src/app/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
return (
<main className="mx-auto max-w-3xl p-8">
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="mt-4 text-gray-700">
Signed in as {session.user.email}.
</p>
</main>
);
}
Account linking and unlinking
Do not automatically link accounts just because two providers return the same email. Let the signed-in user start linking from the settings page. Avoid casually enabling dangerous email-based linking unless you have verified the provider’s email assurance model.
// src/app/settings/accounts/page.tsx
import { signIn } from "@/auth";
import { LinkedAccounts } from "./linked-accounts";
export default function AccountSettingsPage() {
return (
<main className="mx-auto max-w-2xl p-8">
<h1 className="text-2xl font-bold">Login methods</h1>
<p className="mt-2 text-sm text-gray-600">
Add another provider only while you are already signed in.
</p>
<div className="mt-6 flex gap-3">
<form
action={async () => {
"use server";
await signIn("google", { redirectTo: "/settings/accounts" });
}}
>
<button className="rounded-md border px-4 py-2">Link Google</button>
</form>
<form
action={async () => {
"use server";
await signIn("github", { redirectTo: "/settings/accounts" });
}}
>
<button className="rounded-md border px-4 py-2">Link GitHub</button>
</form>
</div>
<LinkedAccounts />
</main>
);
}
// src/app/api/settings/linked-accounts/route.ts
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
function isSameOrigin(request: Request) {
const origin = request.headers.get("origin");
const host = request.headers.get("host");
if (!origin || !host) return false;
try {
return new URL(origin).host === host;
} catch {
return false;
}
}
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const accounts = await prisma.account.findMany({
where: { userId: session.user.id },
select: { provider: true, providerAccountId: true },
orderBy: { provider: "asc" },
});
return NextResponse.json(accounts);
}
export async function DELETE(request: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!isSameOrigin(request)) {
return NextResponse.json({ error: "Bad origin" }, { status: 403 });
}
const body = (await request.json()) as { provider?: string };
const provider = body.provider;
if (!provider) {
return NextResponse.json({ error: "Provider is required" }, { status: 400 });
}
const accounts = await prisma.account.findMany({
where: { userId: session.user.id },
select: { provider: true },
});
if (accounts.length <= 1) {
return NextResponse.json(
{ error: "You cannot remove the last login method." },
{ status: 400 },
);
}
await prisma.account.deleteMany({
where: { userId: session.user.id, provider },
});
return NextResponse.json({ ok: true });
}
Claude Code prompt and review checklist
Use a prompt that names the security requirements, not just the UI.
Add Google and GitHub social login to this Next.js App Router app with Auth.js v5.
Requirements:
- Read client secrets only from .env.local or the deployment secret store.
- Google scope must be openid email profile.
- GitHub scope must be read:user user:email.
- Allow Google sign-in only when email_verified is true.
- Add only user.id to the session and never expose provider access_token to the client.
- Account linking must start from a signed-in settings page.
- Users must not be able to unlink their last login method.
- After editing, report lint results and manual OAuth test steps.
| Review area | What to check |
|---|---|
| OAuth flow | Authorization code flow is used and redirect URIs match provider dashboards |
| State and CSRF | Auth.js standard routes are used, custom APIs verify same origin |
| Cookies and sessions | Production cookies are Secure, HttpOnly, SameSite-aware, and tokens stay server-side |
| Account linking | Verified email and explicit user intent are required before linking accounts |
Real use cases
First, a B2B SaaS admin panel can use Google as the primary login path and later add Workspace domain checks or RBAC. GitHub can remain available only for technical users.
Second, a developer tool can use GitHub login to shorten onboarding, then request repository permissions only inside the feature that needs repository access. This keeps the first consent screen small.
Third, an existing email-password product can add social login safely by asking signed-in users to link Google or GitHub from settings. This avoids surprise account merges when two providers return the same email.
Fourth, a training or webinar site can reduce form friction while still using verified Google email for support and attendance workflows.
Operational failure cases
Redirect URI mismatch is the common production failure. If local login works but production fails, compare AUTH_URL, proxy host headers, and the Google or GitHub dashboard callback URL.
Unsafe email-based account linking is more subtle. Google exposes email_verified, but not every provider gives the same assurance. Do not treat every email string as equal proof of identity.
Excessive scopes hurt conversion and support. A user will not trust a login button that asks for repository access or file access before they understand the product.
Hard-coded client secrets should block the review. Even placeholder-looking code becomes dangerous when someone later pastes a real value into it. Use .env.local, deployment secrets, or a managed secret store.
Google refresh tokens can surprise teams. Google may only return a refresh token on the first consent. For pure login, do not store refresh tokens. For background Google API work, create a separate token rotation task.
Official references and internal links
Use primary references before changing the flow: Auth.js, Google Identity Services OAuth flow, GitHub OAuth App authorization, and the OWASP Authentication Cheat Sheet.
For related implementation details, read OAuth implementation with Claude Code, JWT authentication with Claude Code, Claude Code security best practices, and environment variable management.
Consultation and verification points
ClaudeCodeLab can help turn this from a login button into a reviewed authentication workflow: requirements, Claude Code task design, implementation review, secret handling, and manual OAuth testing. For teams planning social login, a short architecture review often prevents the expensive support issues that appear after launch.
When you try this article’s implementation, verify these points: development and production redirect URIs match exactly, Google email_verified is enforced, GitHub private-email failures have a clear message, state and cookies stay on the Auth.js standard flow, the final login method cannot be removed, and no client secret appears in source code or logs.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.