Use Cases (更新: 2026/6/7)

「Googleでログイン」ボタンを1日で。Auth.jsでソーシャルログインを安全に組む

Auth.js(NextAuth v5)でGoogle/GitHubのソーシャルログインを実装する手順。OAuth/OIDCの流れ、stateとコールバック、メール紐付け、複数プロバイダの落とし穴まで動くコードで解説。

「Googleでログイン」ボタンを1日で。Auth.jsでソーシャルログインを安全に組む

「Googleでログイン、ちゃちゃっと付けといて」

そう頼んで出てきた画面は、ちゃんと動きました。Googleの同意画面も出るし、ダッシュボードにも入れる。でも本番に上げた瞬間、redirect_uri_mismatch で全員ログインできなくなった。しかも検証中、たまたま同じメールの別アカウントにスッと入れてしまって、背筋が凍りました。

ソーシャルログインは「ボタンを1個置く」仕事に見えて、裏ではOAuthの往復、stateの検証、戻り先URLの完全一致、メールの紐付け判断が同時に走っています。ここを曖昧なまま任せると、動く画面の下に事故が埋まる。

この記事では、Next.jsとAuth.js(NextAuth v5系)で、GoogleとGitHubのソーシャルログインを安全に組む手順を、僕が踏んだ地雷ごと書きます。自前でフローを全部書くのではなく、枯れたライブラリに面倒な部分を任せるのが今回の方針です。

この記事の要点

  • ソーシャルログインの本体は「画面」ではなく、OAuth/OIDCの往復とstate検証、戻り先URLの一致。ここを外すと事故る
  • 自前でURLを文字列連結せず、Auth.js(NextAuth v5)にstate・Cookie・コールバックを任せる。これだけで定番ミスが大幅に減る
  • scopeはログインに必要な最小だけ。Googleはopenid email profile、GitHubはread:user user:emailに絞る
  • 同じメールでも自動で紐付けない。アカウント連携は「ログイン済みの本人が設定画面から明示的に」が鉄則
  • 一番多い失敗はredirect_uriの不一致、未検証メールの自動連携、過剰scope、client secretの直書きの4つ

そもそもOAuthとOIDCって何が往復してるのか

最初に言葉を一回だけ整理します。難しく見えますが、要は「鍵の受け渡し」です。

  • OAuth … 「このアプリにあなたの代わりに○○させていいですか?」の許可をもらう仕組み。許可の対象は権限(scope)
  • OIDC … OAuthの上に「で、あなた誰?」という本人確認を乗せたもの。id_tokenという身分証が返る
  • 認可コード(authorization code) … プロバイダーが一瞬だけ発行する引換券。これ自体は秘密情報ではない
  • state … ログイン開始時にこちらが発行する乱数。戻ってきた値と一致するかでCSRFを弾く
  • redirect URI … プロバイダーから戻ってくる「番地」。1文字でもズレると拒否される

ソーシャルログインで実際に起きているのは、ざっくりこの流れです。

1. ユーザーが「Googleで続ける」を押す
2. アプリ → Google へリダイレクト(client_id, redirect_uri, scope, state を持って)
3. Google が同意画面を出す → ユーザーが許可
4. Google → アプリの redirect URI へ戻る(code と state を付けて)
5. アプリのサーバーが state を照合し、code を token endpoint で交換(client secret を使う)
6. Google が id_token / access_token を返す
7. アプリがセッションCookieを発行して、ダッシュボードへ

ポイントは2つ。コードの交換(5番)はサーバー側でやること。client secretをブラウザに出したら一発アウトです。そして**stateの照合(5番)を絶対に省かない**こと。ここを飛ばすと、第三者が仕掛けたログインに乗せられます。GitHubの公式ドキュメントも、stateが一致しないリクエストは中止すべきだと明記しています。

このあたりを自前で書こうとすると抜けが出ます。だから今回はAuth.jsに任せる。手で書くフロー(PKCE含む)を学びたい人は、別記事の認可コードフローを自前で組む話も後で読んでください。

Claude Codeに投げる前に、人間が決めておくこと

Claude Codeは差分を作るのは速いです。でも、外部コンソールの設定値(redirect URI、scope、Cookie方針)までは勝手に確定できません。ここを曖昧なままプロンプトに流すと、それっぽいが詰めの甘いコードが返ってきます。

僕は先に、この4つだけ表に落としてから依頼します。

決める項目今回の例なぜ先に決めるか
プロバイダーGoogle + GitHub増やすほど同意画面と監査が複雑になる
scopeGoogle: openid email profile / GitHub: read:user user:email過剰scopeは離脱と不信を生む
メール紐付け自動連携しない(手動のみ)別人のアカウントに入る事故を防ぐ
戻り先URLdev: localhost:3000 / prod: 本番ドメイン不一致が最頻の失敗

そのうえで、作業はこのくらい小さく切ると差分レビューが効きます。

  1. providerの設定・環境変数・redirect URIを追加する
  2. ログイン画面と保護ページを足す
  3. セッションにはuser.idだけ乗せ、access tokenはクライアントへ出さない
  4. アカウント連携は「ログイン済みの本人」だけが追加できるようにする
  5. 解除APIで「最後のログイン手段」は消せないようにする

僕が検証で一番溶かした時間は、コード生成じゃなくて、Google Cloud Consoleのredirect URIとアプリのAUTH_URLが微妙にズレてた問題でした。コードは正しいのに動かない。コンソール側は人間が確定させる、と割り切るのが結局速いです。

セットアップ:Next.js App RouterとAuth.js

ここから手を動かします。Next.js App Router、TypeScript、Prisma Adapter、PostgreSQLの最小構成です。

npm install next-auth@beta @auth/prisma-adapter prisma @prisma/client
npx prisma init
npm exec auth secret

NextAuth v5はAUTH_で始まる環境変数を自動で拾います。だから命名を合わせるだけで配線が減ります。

# .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は必ず環境変数で持ちます。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=

戻り先URLは、開発がhttp://localhost:3000/api/auth/callback/google、本番がhttps://example.com/api/auth/callback/google。GitHub OAuth AppのCallback URLも.../api/auth/callback/githubに合わせます。末尾のprovider名まで一致させるのがコツです。googlegithubを取り違えただけで戻れません。

Prisma schemaを用意する

Auth.jsのAdapterは、User・Account・Session・VerificationTokenの4テーブルを使います。AccountproviderproviderAccountIdが入り、これがアカウント連携の土台になります。

// 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;

コピペで動く:GoogleとGitHubのprovider設定

ここが今回の心臓部です。ログイン用途に絞ったauth.tsを、そのまま写して動くように置きます。読みどころは2つのコールバックだけ。signInが「誰を入れるか」の門番、sessionが「セッションに何を載せるか」の絞り込みです。

Googleはemail_verifiedtrueのときだけ通します。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: {
          // ログインに要るぶんだけ。Drive等のscopeは付けない
          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;
        // Googleは検証済みメール必須
        return Boolean(googleProfile?.email && googleProfile.email_verified);
      }

      if (account?.provider === "github") {
        // GitHubはメールが取れない人を拒否
        return Boolean(user.email);
      }

      return true;
    },
    // セッションには user.id だけ。token類はクライアントに出さない
    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);

セッションの型を拡張して、session.user.idをTypeScriptに認識させます。

// 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;
    };
  }
}

ルートハンドラはこれだけ。stateやCSRF用Cookieの処理は、この標準ルートが面倒を見てくれます。

// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";

export const { GET, POST } = handlers;

ここまでで「動く土台」は完成です。次にログイン画面を足します。Server ActionからsignInを呼ぶと、URLを自分で文字列連結するよりstate・Cookie・コールバックの取りこぼしが減ります。

// 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>
  );
}

保護ページ側は、auth()でセッションを取り、無ければログインへ飛ばすだけです。

// 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>
  );
}

複数プロバイダの肝:アカウント連携と解除

ここが一番設計を間違えやすいところです。「同じメールだから同じ人でしょ」と自動で紐付けたくなる。でもそれが冒頭の「別人のアカウントに入れた」事故の正体です。

Auth.jsにはallowDangerousEmailAccountLinkingという設定があります。名前にDangerousが入っているのは伊達じゃない。メール検証の弱いproviderを踏み台に、攻撃者が他人のメールでOAuthアカウントを作り、被害者のアカウントへ入り込めるからです。だから僕は自動連携はオフのまま、連携は「ログイン済みの本人が設定画面から明示的に」やる形にします。

// 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>
  );
}

解除APIには、地味だが効く門番を2つ置きます。Origin確認と、「最後の1個は消させない」チェックです。最後のログイン手段まで解除できると、誰もそのアカウントに入れなくなる詰み状態が生まれます。

// 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 });
}

クライアント側はこの2つのAPIを叩くだけなので割愛しますが、連携一覧と解除ボタンを並べたlinked-accounts.tsxsettings/accountsに置けば画面が揃います。パスワード認証と併用するなら、ソーシャル連携を外しても入れるようパスワード再設定の導線を残しておくと安全網になります。

僕がソーシャルログインでやらかした失敗4つ

正直に書きます。最初の実装は地雷だらけでした。

ひとつ目、redirect_uriの不一致。 ローカルでは完璧、本番で全滅。原因はAUTH_URLとプロキシのHostヘッダ、コンソール側Callback URLの三者がズレていたこと。Vercelやリバースプロキシの裏では、本番URLで必ず再確認するクセを付けました。

ふたつ目、未検証メールの自動連携。 これが冒頭の「別人に入れた」事故。Googleはemail_verifiedを返しますが、全providerが同じ強度でメールを保証するわけじゃない。OWASPの認証チートシートでも、識別情報の扱いは慎重にと釘を刺しています。今はsignInコールバックで検証済みメールを必須にしています。

みっつ目、過剰scope。 便利だろうとGitHubでrepoまで要求したら、同意画面で「なんでログインにリポジトリ全権限?」と離脱されました。ログイン、プロフィール取得、外部API操作は別物。必要になった画面で追加同意を取る形にしたら、CVRが戻りました。

よっつ目、client secretの直書き。 Claude Codeが生成したコードに"GITHUB_SECRET"みたいなプレースホルダーが入っていても、そこに実値を貼る場所ではありません。.env.localかシークレットマネージャー、CIのsecret storeに分離。レビューで必ず止める一点です。

おまけで、Googleのrefresh_tokenが2回目以降のログインで返らない件にもハマりました。Googleは初回同意時だけ返すことがあります。ログインだけならrefresh tokenは保存しない、Google APIを叩くなら再同意やtoken rotationの設計を別チケットに切る、で回避しました。

よくある質問

Q. 自分でOAuthのURLを組むのと、Auth.jsを使うのはどっちがいい? 学習目的や特殊要件がなければAuth.jsです。state・PKCE・Cookie・コールバックという「抜けると事故る部分」を任せられます。仕組みを理解したうえで自前実装したいなら、認可コードフローを手で組む記事で土台を学んでから判断してください。

Q. GitHubでメールが取れないユーザーがいるのはなぜ? GitHubはメールを非公開に設定できるからです。user:email scopeを付けても取得できないことがあります。今回のsignInコールバックのように、メールが無いアカウントは丁寧にエラーへ落とすのが安全です。

Q. 同じメールのGoogleとGitHubを自動でひとつにまとめたい。 やめておくのが無難です。allowDangerousEmailAccountLinkingで自動連携はできますが、メール検証の弱いproviderが混ざると乗っ取りの口になります。ログイン済みの本人が設定画面から手動で連携する形にしてください。

Q. アクセストークンをフロントで使いたい場合は? 原則クライアントへ出しません。外部APIを叩くなら、トークンはサーバー側(DBのAccountやセッション)に置き、サーバー経由で呼びます。セッション設計やトークンの考え方は認証実装の基本記事で整理しています。

Q. ソーシャルログインに2要素認証も足せる? 足せます。ソーシャルログイン後にアプリ独自のTOTP確認を挟む構成が一般的です。詰みやすいのは復旧フローなので、2FAとリカバリーコードの記事を先に読んでおくと安全です。

公式ドキュメントと関連記事

一次情報は必ず当たってください。Auth.jsの基本はAuth.js公式ドキュメント、Googleの認可コードフローとscopeはGoogle IdentityのOAuth解説、GitHubのstateredirect_uri・scopeはGitHub OAuth Appsの認可ドキュメント、認証全体の防御観点はOWASP Authentication Cheat Sheetが基準です。

社内で実装を回すなら、認証仕様・レビュー観点・運用チェックリストまで含めて設計するのが結局の近道です。腰を据えて学びたい人は教材一覧も覗いてみてください。

実際に試した結果

冒頭の事故以来、僕はソーシャルログインで「動いた」を信用しなくなりました。代わりに毎回チェックするのは、開発と本番のredirect URIが完全一致しているか、Googleのemail_verifiedを見ているか、GitHubのメール非公開でエラーが自然か、stateとCookieをAuth.js標準フローに任せているか、最後のログイン手段を解除できないか、client secretをコードやログに出していないか、の6点です。

このリストを通すようにしてから、本番での「全員ログインできない」も「別人のアカウントに入れた」も再発していません。ボタンを置くより先に、誰に・どのproviderで・どの権限だけを求めるかを表に書く。遠回りに見えて、これが一番速い、というのが今の実感です。

#Claude Code #ソーシャルログイン #OAuth #OIDC #Auth.js #NextAuth.js
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。