Claude Codeでソーシャルログインを実装: Next.js/Auth.jsとGoogle/GitHub OAuthの安全設計
Claude CodeでNext.js/Auth.jsのソーシャルログインを安全に実装する手順を、Google/GitHub OAuthと運用失敗例まで解説。
ソーシャルログインは、登録フォームを短くするだけの機能ではありません。GoogleやGitHubのOAuthを入口にすると、ユーザー確認、アカウント連携、セッション、Cookie、CSRF、監査ログまで一気に設計対象になります。Claude Codeに「ログインを作って」と丸投げすると、動く画面は出ても、redirect URIの不一致、未検証メールの自動連携、過剰なscope、client secretの露出といった運用事故が残りがちです。
この記事では、Next.js App RouterとAuth.js、つまりNextAuth v5系の構成で、Google providerとGitHub providerを安全に導入する手順をまとめます。OAuth authorization codeは「認可コード」と呼ばれる一時的な交換券、stateは「CSRFを検出するための乱数」、redirect URIは「プロバイダーから戻る正確なURL」、account linkingは「同じ利用者が複数のログイン手段を明示的に紐付けること」として扱います。専門用語をClaude Codeに曖昧なまま渡さないことが、実装品質を上げる近道です。
Claude Codeに任せる前に決める境界
最初に決めるのは、プロバイダーの数ではなく、認証で何を保証したいかです。一般的なSaaSならGoogleとGitHubの2つで十分です。Googleは一般ユーザーと企業メールに強く、GitHubは開発者向けツールや技術研修の申込に向いています。一方で、ログイン目的なのにGoogle CalendarやGitHub repo scopeまで要求すると、同意画面で離脱が増え、審査や監査の説明も難しくなります。
Claude Codeに渡す作業単位は、次のように小さく切るとレビューしやすくなります。
- Auth.jsのprovider設定、環境変数、redirect URIを追加する
- ログイン画面と保護ページを追加する
- セッションに
user.idだけを拡張し、アクセストークンをクライアントへ出さない - アカウント連携画面では、現在ログイン済みのユーザーだけが追加providerを紐付けられるようにする
- 解除APIでは、最後のログイン手段を消せないようにする
- client secret、refresh token、access tokenをコード、ログ、Markdownに直書きしない
Masaが検証環境で一番時間を使ったのは、コード生成そのものではなく、Google Cloud Consoleのredirect URIとアプリ側のAUTH_URLが微妙にずれていた問題でした。Claude Codeは差分作成に強い一方、外部コンソールの設定値までは自動で確定できません。だから、URL、scope、Cookie設定、メール検証の判断だけは人間が先に表へ落としておくべきです。
OAuthの流れを図で押さえる
Googleの公式ガイドでも、Webアプリでは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はログイン開始時に発行した乱数と、戻ってきた値が一致するかを確かめるための値です。Auth.jsの標準ルートを使えばstateやCSRF用Cookieの処理を任せられます。独自callbackを作る場合は、ここを省略してはいけません。GitHubの公式ドキュメントも、stateが一致しない場合は第三者が開始したリクエストとして中止するべきだと説明しています。
実装前提: Next.js App RouterとAuth.js
以下は、Next.js App Router、TypeScript、Prisma Adapter、PostgreSQLを使う最小構成です。SQLiteなど別DBで試す場合は、Prisma schemaのDB固有属性を使うDBに合わせて調整してください。ログイン目的なので、scopeはGoogleがopenid email profile、GitHubがread:user user:emailに絞ります。GitHubのrepoやGoogle Drive scopeは、ログインだけなら不要です。
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"
AUTH_GOOGLE_SECRETやAUTH_GITHUB_SECRETは、必ず環境変数で管理します。Claude Codeへのプロンプト、GitHub issue、Slack、記事下書きに実値を貼らないでください。サンプルが必要なときは.env.exampleに空のキーだけを置きます。
# .env.example
AUTH_SECRET=
AUTH_URL=
AUTH_GOOGLE_ID=
AUTH_GOOGLE_SECRET=
AUTH_GITHUB_ID=
AUTH_GITHUB_SECRET=
DATABASE_URL=
Google Cloud ConsoleのAuthorized redirect URIは、開発環境ならhttp://localhost:3000/api/auth/callback/google、本番ならhttps://example.com/api/auth/callback/googleです。GitHub OAuth AppのAuthorization callback URLもhttps://example.com/api/auth/callback/githubに合わせます。パス末尾のprovider名まで一致させるのがポイントです。
Prisma schemaを準備する
Auth.jsのAdapterを使うと、User、Account、Session、VerificationTokenのテーブルを使えます。Accountにはprovider名とproviderAccountIdが入り、これがアカウント連携の土台になります。
// 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設定: GoogleとGitHub provider
次のauth.tsは、ログイン用途に絞った設定です。Googleはemail_verifiedを確認し、GitHubはメールが取れないアカウントを拒否します。GitHubではユーザーがメールを非公開にしていることがあるため、user:email scopeを付けてもメールが取れないケースを想定します。
// 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;
ログイン画面と保護ページ
Server ActionからsignInを呼ぶと、Auth.jsの標準フローに乗せられます。自前で/api/loginを作ってprovider URLを文字列連結するより、state、Cookie、callback処理の抜け漏れを減らせます。
// 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">
業務で使うメールまたはGitHubアカウントを選択してください。
</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">
{session.user.email} としてログイン中です。
</p>
</main>
);
}
アカウント連携と解除を安全に作る
アカウント連携では、同じメールだから自動で紐付けるのではなく、ログイン済みユーザーが設定画面から明示的に追加する流れにします。allowDangerousEmailAccountLinkingを安易に使うと、メール検証の弱いproviderを経由して別人のアカウントに紐付くリスクがあります。
// 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">ログイン方法</h1>
<p className="mt-2 text-sm text-gray-600">
追加のログイン方法は、現在ログイン中の状態でだけ連携できます。
</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">Googleを連携</button>
</form>
<form
action={async () => {
"use server";
await signIn("github", { redirectTo: "/settings/accounts" });
}}
>
<button className="rounded-md border px-4 py-2">GitHubを連携</button>
</form>
</div>
<LinkedAccounts />
</main>
);
}
// src/app/settings/accounts/linked-accounts.tsx
"use client";
import { useEffect, useState } from "react";
type LinkedAccount = {
provider: string;
providerAccountId: string;
};
export function LinkedAccounts() {
const [accounts, setAccounts] = useState<LinkedAccount[]>([]);
const [message, setMessage] = useState<string | null>(null);
async function loadAccounts() {
const response = await fetch("/api/settings/linked-accounts", {
credentials: "same-origin",
});
setAccounts(await response.json());
}
useEffect(() => {
void loadAccounts();
}, []);
async function unlink(provider: string) {
const response = await fetch("/api/settings/linked-accounts", {
method: "DELETE",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ provider }),
});
if (!response.ok) {
const body = (await response.json()) as { error?: string };
setMessage(body.error ?? "解除できませんでした。");
return;
}
setMessage("ログイン方法を解除しました。");
await loadAccounts();
}
return (
<section className="mt-8">
<h2 className="text-lg font-semibold">連携済み</h2>
{message ? <p className="mt-3 text-sm text-gray-700">{message}</p> : null}
<ul className="mt-4 divide-y rounded-md border">
{accounts.map((account) => (
<li key={account.provider} className="flex items-center justify-between p-4">
<span className="capitalize">{account.provider}</span>
<button
type="button"
onClick={() => unlink(account.provider)}
className="text-sm text-red-700 hover:underline"
>
解除
</button>
</li>
))}
</ul>
</section>
);
}
// 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: "最後のログイン方法は解除できません。" },
{ status: 400 },
);
}
await prisma.account.deleteMany({
where: { userId: session.user.id, provider },
});
return NextResponse.json({ ok: true });
}
Claude Codeへの依頼粒度とレビュー観点
Claude Codeには、セキュリティ要件を含む小さなチケットとして渡します。例えば次のような依頼です。
Next.js App Router と Auth.js v5 で Google/GitHub ログインを追加してください。
要件:
- client secret は .env.local だけで読み、コードやログへ出さない
- Google scope は openid email profile のみ
- GitHub scope は read:user user:email のみ
- Google は email_verified が true の場合だけ許可
- セッションには user.id だけ追加し、access_token はクライアントへ返さない
- アカウント連携はログイン済みユーザーの設定画面からだけ実行
- 最後のログイン方法を解除できないようにする
- 変更後に npm run lint と認証フローの手動確認手順を出す
レビューでは、次の4点を必ず見ます。
| 観点 | 確認すること |
|---|---|
| OAuth flow | authorization code flowを使い、redirect URIがprovider設定と一致しているか |
| stateとCSRF | Auth.js標準ルートに乗っているか。独自APIはOrigin確認などの防御があるか |
| Cookieとsession | 本番でSecure、HttpOnly、SameSite前提になっているか。tokenをブラウザへ出していないか |
| Account linking | verified emailを確認し、同じメールだけで危険な自動連携をしていないか |
実例とユースケース
1つ目は、B2B SaaSの管理画面です。Google Workspaceのメールでログインさせ、将来的にhdドメイン制限やRBACを追加します。ここではGoogle providerを主経路にし、GitHubは開発者だけに許可します。
2つ目は、開発者向けツールです。GitHubログインでユーザー登録を短縮し、後段でGitHub Appや別APIを使ってリポジトリ連携を行います。ログインの時点でrepo scopeを求めず、必要になった画面で追加同意を取ると離脱が減ります。
3つ目は、既存のメールパスワード認証にソーシャルログインを追加するケースです。既存ユーザーがログイン後にGoogleやGitHubを連携する流れにし、未ログイン状態で同じメールを見つけて自動統合しない設計にします。サポート問い合わせが多いサービスほど、明示的な連携画面が効きます。
4つ目は、研修やウェビナーの申込サイトです。フォーム入力を減らしてCVRを上げたい一方で、主催者側は受講者の実在性を確認したい。Googleのverified emailを使うと、問い合わせ前の本人確認コストを下げられます。
運用で壊れやすい失敗例
redirect URIの不一致は最も多い失敗です。localhostでは動くのに本番で失敗する場合、AUTH_URL、プロキシのHost header、Google/GitHubコンソールのcallback URLがずれています。Vercelやリバースプロキシ配下では、本番URLで再確認します。
未検証メールの自動連携も危険です。Googleはemail_verifiedを返しますが、すべてのproviderが同じ強度でメールを保証するわけではありません。OWASPの認証チートシートでも、認証の応答や識別情報の扱いは情報漏えいや列挙につながらないよう注意が必要です。
過剰scopeはPVと問い合わせの両方を落とします。ユーザーは「なぜログインだけでリポジトリ全権限が必要なのか」と不安になります。ログイン、プロフィール取得、外部API操作を分けて、後から追加同意を取る設計にしてください。
client secretの直書きは、レビューで必ず止めるべきです。Claude Codeが生成したコードに"GITHUB_SECRET"のようなプレースホルダーが入っていても、実値を貼る場所ではありません。.env.local、シークレットマネージャー、CIのsecret storeに分離します。
Googleのrefresh tokenが2回目以降のログインで返らない問題もあります。公式ドキュメントでは、Googleは初回同意時だけrefresh tokenを返すケースがあると説明されています。ログインだけならrefresh tokenを保存しない、Google APIを非同期に呼ぶなら再同意やtoken rotationの設計を別チケットに分けるのが安全です。
公式情報と内部リンク
一次情報は必ず確認します。Auth.jsの基本はAuth.js公式ドキュメントを、Googleのauthorization code flowとscopeの考え方はGoogle Identity ServicesのOAuth解説を、GitHubのstate、redirect_uri、scopeはGitHub OAuth Appsの認可ドキュメントを参照してください。認証全体の防御観点はOWASP Authentication Cheat Sheetが基準になります。
関連する実装の深掘りは、Claude CodeでOAuth認証を実装するガイド、Claude CodeでJWT認証を扱う方法、Claude Codeのセキュリティベストプラクティス、環境変数管理の実践も合わせて確認してください。
相談、研修、実装支援につなげるポイント
ClaudeCodeLabでは、Claude Codeにログイン実装を任せるだけでなく、認証仕様、レビュー観点、運用チェックリストまで含めた支援を行います。OAuth導入で問い合わせを増やしたい場合は、ログインボタンの追加より先に、どのユーザーに、どのproviderで、どの権限だけを求めるかを一緒に整理すると効果が出やすくなります。研修では、この記事のコードを題材に、Claude Codeへの依頼、差分レビュー、secret管理、手動検証までを一連の演習にできます。
この記事で紹介した内容を実際に試すときの確認ポイントは、開発と本番のredirect URIが完全一致していること、Googleのemail_verifiedを見ていること、GitHubメール非公開時のエラーが自然であること、stateとCookieをAuth.js標準フローに任せていること、最後のログイン方法を解除できないこと、client secretをリポジトリやログへ出していないことです。ここまで通れば、単なるソーシャルログイン実装ではなく、AdSense審査や商談導線にも耐えやすい認証記事と実装になります。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。