Use Cases (Updated: 6/1/2026)

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.

Implement Social Login with Claude Code: Secure Next.js, Auth.js, Google, and GitHub OAuth

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 areaWhat to check
OAuth flowAuthorization code flow is used and redirect URIs match provider dashboards
State and CSRFAuth.js standard routes are used, custom APIs verify same origin
Cookies and sessionsProduction cookies are Secure, HttpOnly, SameSite-aware, and tokens stay server-side
Account linkingVerified 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.

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.

#Claude Code #social login #OAuth #Auth.js #NextAuth.js #authentication
Free

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.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.