Use Cases (업데이트: 2026. 6. 1.)

Claude Code로 소셜 로그인을 구현하기: Next.js, Auth.js, Google/GitHub OAuth 보안 설계

Claude Code로 Next.js/Auth.js 소셜 로그인을 안전하게 구현하는 방법을 Google/GitHub OAuth 코드와 운영 실수까지 설명합니다.

Claude Code로 소셜 로그인을 구현하기: Next.js, Auth.js, Google/GitHub OAuth 보안 설계

소셜 로그인은 가입 폼을 줄이는 기능만이 아닙니다. Google 또는 GitHub OAuth로 사용자를 받는 순간, 사용자 식별, 계정 연결, 세션, 쿠키, CSRF, provider scope, 감사 로그, 고객 지원까지 함께 설계해야 합니다. Claude Code에 “로그인 만들어 줘”라고만 요청하면 버튼은 생기지만, redirect URI 불일치, 검증되지 않은 이메일 자동 연결, 과도한 scope, client secret 노출 같은 운영 문제가 남을 수 있습니다.

이 글은 Next.js App Router, Auth.js 즉 NextAuth v5 계열 API, TypeScript, Prisma Adapter를 기준으로 소셜 로그인을 실제 서비스 수준으로 구현하는 방법을 정리합니다. OAuth authorization code는 “서버에서 토큰으로 교환하는 임시 코드”, state는 “CSRF를 감지하기 위한 난수”, redirect URI는 “provider가 브라우저를 돌려보내는 정확한 URL”, account linking은 “로그인된 사용자가 다른 로그인 방식을 명시적으로 연결하는 작업”으로 이해하면 됩니다.

먼저 보안 경계를 정한다

처음 결정할 것은 provider 개수가 아니라 “로그인이 무엇을 보장해야 하는가”입니다. SaaS, 교육 신청, 개발자 도구라면 Google과 GitHub 두 개만으로도 충분한 경우가 많습니다. Google은 일반 사용자와 회사 이메일에 적합하고, GitHub는 개발자 제품이나 기술 커뮤니티에 적합합니다. 로그인만 필요한데 Google Drive나 GitHub repo 권한을 요구하면 동의 화면에서 이탈이 늘고, 보안 설명도 어려워집니다.

Claude Code에는 다음처럼 작은 작업 단위로 맡기는 편이 안전합니다.

  • Auth.js provider, 환경 변수, callback route 추가
  • 로그인 페이지와 보호 페이지 구현
  • Session에는 user.id만 추가하고 access token은 클라이언트로 보내지 않기
  • 계정 연결은 로그인된 사용자가 설정 화면에서 직접 시작하게 하기
  • 마지막 로그인 방법은 삭제하지 못하게 하기
  • client secret, refresh token, access token을 코드, 로그, 이슈, 문서에 쓰지 않기

Masa가 검증 환경에서 가장 많이 시간을 쓴 부분은 코드가 아니라 Google Cloud Console의 redirect URI와 앱의AUTH_URL이 어긋난 문제였습니다. Claude Code는 코드 차이를 잘 만들지만 외부 콘솔 설정은 자동으로 확정할 수 없습니다. URL, scope, 이메일 검증 규칙, 쿠키 정책은 구현 전에 체크리스트로 고정해야 합니다.

OAuth 흐름을 그림으로 보기

Google 공식 가이드에서도 웹 애플리케이션에는 authorization code flow를 권장합니다. 브라우저가 임시code를 받고, 서버가 client secret으로 token endpoint에 교환합니다. client secret은 브라우저 코드에 들어가면 안 됩니다.

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는 돌아온 callback이 내가 시작한 로그인 요청의 결과인지 확인하는 값입니다. Auth.js 표준 route를 사용하면 state와 CSRF 쿠키 처리를 맡길 수 있습니다. 직접 callback을 만들 때는 state 검증을 빼면 안 됩니다. GitHub 문서도 예측하기 어려운state를 권장하며, 값이 맞지 않으면 흐름을 중단해야 한다고 설명합니다.

환경 변수와 최소 scope

다음 예시는 로그인 목적에 맞춰 scope를 최소화합니다. Google은openid email profile, GitHub는read:user user:email만 사용합니다.

npm install next-auth@beta @auth/prisma-adapter prisma @prisma/client
npx prisma init
npm exec auth secret
# .env.local
AUTH_SECRET="npm exec auth secret 으로 생성한 값"
AUTH_URL="http://localhost:3000"
AUTH_GOOGLE_ID="Google Cloud Console client ID"
AUTH_GOOGLE_SECRET="Google Cloud Console client secret"
AUTH_GITHUB_ID="GitHub OAuth App client ID"
AUTH_GITHUB_SECRET="GitHub OAuth App client secret"
DATABASE_URL="postgresql://user:password@localhost:5432/app"

실제 secret은 환경 변수나 secret manager에만 둡니다. 저장소에 올리는 예시는 빈 값으로 둡니다.

# .env.example
AUTH_SECRET=
AUTH_URL=
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=
DATABASE_URL=

Google 개발 환경 redirect URI는http://localhost:3000/api/auth/callback/google, 운영 환경은https://example.com/api/auth/callback/google입니다. GitHub OAuth App은https://example.com/api/auth/callback/github로 맞춥니다. 프로토콜, 도메인, 경로, provider 이름이 모두 같아야 합니다.

Auth.js 설정 코드

아래 설정은 Google의email_verified가 참일 때만 허용하고, GitHub에서 이메일을 얻지 못하면 로그인시키지 않습니다. provider token은 브라우저 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;

Prisma Adapter를 쓰는 경우 Claude Code에User, Account, Session, VerificationToken모델도 함께 추가하라고 지시합니다. 특히AccountproviderproviderAccountId는 계정 연결을 추적하는 핵심 데이터입니다.

로그인 페이지와 보호 페이지

Server Action에서signIn을 호출하면 Auth.js 표준 flow를 유지할 수 있습니다. provider URL을 직접 문자열로 조립하는 방식보다 state와 쿠키 처리를 놓칠 가능성이 낮습니다.

// src/app/login/page.tsx
import { signIn } from "@/auth";

const providers = [
  { id: "google", label: "Google로 계속" },
  { id: "github", label: "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">로그인</h1>
        <p className="mt-2 text-sm text-gray-600">
          이 애플리케이션에서 사용할 업무 계정을 선택하세요.
        </p>
      </div>

      {searchParams.error ? (
        <p className="rounded-md bg-red-50 p-3 text-sm text-red-700">
          로그인에 실패했습니다. 다른 계정을 시도하거나 관리자에게 문의하세요.
        </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>
  );
}

계정 연결과 해제

같은 이메일이 반환되었다는 이유만으로 자동 연결하지 마세요. 로그인된 사용자가 설정 화면에서 명시적으로 연결을 시작해야 합니다. 해제 API는 마지막 로그인 수단을 삭제하지 못하게 하고, 같은 출처 요청인지 확인합니다.

// 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 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 accounts = await prisma.account.findMany({
    where: { userId: session.user.id },
    select: { provider: true },
  });

  if (accounts.length <= 1) {
    return NextResponse.json(
      { error: "마지막 로그인 방법은 삭제할 수 없습니다." },
      { status: 400 },
    );
  }

  await prisma.account.deleteMany({
    where: { userId: session.user.id, provider: body.provider },
  });

  return NextResponse.json({ ok: true });
}

Claude Code 프롬프트와 리뷰 표

Next.js App Router 앱에 Auth.js v5 기반 Google/GitHub 소셜 로그인을 추가하세요.
요구사항:
- client secret은 .env.local 또는 배포 secret에서만 읽기
- Google scope는 openid email profile만 사용
- GitHub scope는 read:user user:email만 사용
- Google은 email_verified가 true일 때만 허용
- Session에는 user.id만 추가하고 access_token을 클라이언트에 노출하지 않기
- 계정 연결은 로그인된 사용자의 설정 페이지에서만 시작
- 마지막 로그인 방법은 해제할 수 없게 하기
- 작업 후 lint 결과와 수동 OAuth 확인 절차를 보고하기
리뷰 항목확인 내용
OAuth flowauthorization code flow를 사용하고 redirect URI가 콘솔 설정과 일치하는가
state와 CSRFAuth.js 표준 route를 유지하고 custom API는 same-origin을 확인하는가
Cookie와 Sessiontoken은 서버에만 있고 production cookie는 Secure, HttpOnly, SameSite를 고려하는가
Account linkingverified email과 사용자 의도가 확인된 뒤에만 연결하는가

실제 유스케이스

첫째, B2B SaaS 관리자 화면입니다. Google을 기본 로그인으로 두고, 나중에 Workspace 도메인 제한과 RBAC를 추가합니다. GitHub는 개발자 계정에만 열어 둘 수 있습니다.

둘째, 개발자 도구입니다. 첫 로그인에서는 GitHub 신원만 사용하고, 저장소 접근 권한은 실제 저장소 기능에 들어갈 때 별도로 요청합니다. 첫 동의 화면이 작아져 전환율이 좋아집니다.

셋째, 기존 이메일 비밀번호 서비스에 소셜 로그인을 추가하는 경우입니다. 이미 로그인한 사용자가 설정에서 Google 또는 GitHub를 연결하게 해야 하며, 미로그인 상태에서 같은 이메일만 보고 자동 병합하면 안 됩니다.

넷째, 교육, 웨비나, 상담 신청 페이지입니다. Google verified email을 활용하면 가짜 신청과 수동 확인 비용을 줄이면서 폼 길이도 줄일 수 있습니다.

운영에서 자주 깨지는 부분

redirect URI 불일치가 가장 흔합니다. 로컬에서는 되는데 운영에서 실패하면AUTH_URL, 프록시 Host header, Google/GitHub callback URL을 비교하세요.

이메일 기반 자동 연결은 더 위험합니다. Google은email_verified를 주지만 모든 provider가 같은 수준의 보장을 제공하지는 않습니다. 동일한 이메일 문자열만으로 같은 사람이라고 판단하지 마세요.

scope가 과하면 PV와 문의 전환이 모두 떨어집니다. 로그인만 하려는 사용자에게 저장소 권한이나 파일 권한을 요구하면 신뢰를 잃습니다.

client secret 직작성은 review에서 반드시 막아야 합니다. 예시 코드처럼 보여도 실제 값을 붙여 넣으면 사고가 됩니다. .env.local, 배포 secret, secret manager를 사용하세요.

Google refresh token도 별도 이슈로 다뤄야 합니다. Google은 처음 동의할 때만 refresh token을 반환할 수 있습니다. 순수 로그인이라면 저장하지 말고, 백그라운드 Google API 호출이 필요할 때 token rotation을 별도로 설계하세요.

공식 자료와 내부 링크

구현 전에 1차 자료를 확인하세요: Auth.js, Google Identity Services OAuth 설명, GitHub OAuth Apps 인증 문서, OWASP Authentication Cheat Sheet.

관련 내부 글로는 Claude Code OAuth 구현, Claude Code JWT 인증, Claude Code 보안 베스트 프랙티스, 환경 변수 관리를 함께 보세요.

상담, 교육, 확인 포인트

ClaudeCodeLab은 로그인 버튼 구현뿐 아니라 요구사항 정리, Claude Code 작업 분해, 코드 리뷰, secret 관리, 수동 OAuth 테스트까지 함께 지원합니다. 소셜 로그인으로 문의 전환을 높이고 싶다면 먼저 대상 사용자, provider, 최소 권한, 오류 메시지를 설계한 뒤 구현하는 편이 좋습니다.

이 글의 내용을 실제로 시험할 때는 개발과 운영 redirect URI가 완전히 일치하는지, Google email_verified를 확인하는지, GitHub 비공개 이메일 실패가 자연스럽게 처리되는지, state와 Cookie가 Auth.js 표준 flow에 남아 있는지, 마지막 로그인 방법을 삭제할 수 없는지, client secret이 코드와 로그에 남지 않는지 확인하세요.

#Claude Code #소셜 로그인 #OAuth #Auth.js #NextAuth.js #인증
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.