デザインについての学習メモブログ

Next.js入門 #10 実践編 認証を実装する — Better Authでログイン機能を作る

記事内に広告が含まれています。

Next.js入門 #10 実践編 認証を実装する — Better Authでログイン機能を作る

前回の#9では、SupabaseとPrismaを使って記事データを永続化しました。

しかし今のままでは、誰でも自由に記事を投稿・編集・削除できてしまいます。

今回は、認証ライブラリを使ってGoogleログインを実装し、「ログインしたユーザーだけが管理画面を使える」という状態を作ります。

この記事は#9のDBセットアップが前提です。

認証情報もデータベースに保存するため、Prismaのスキーマにも手を加えます。

そもそも「認証」とは何か

普段何気なく使う言葉ですが、一度整理しておきます。

認証(Authentication)

「あなたは誰ですか?」を確認するプロセスです。

ログイン画面でメールアドレスとパスワードを入力する、Googleアカウントでログインする、といった一連の流れが認証にあたります。

この記事のテーマです。

似た言葉に「認可」があり、しばしば混同されます。

認可(Authorization)

「あなたに、その操作をする権限がありますか?」を確認するプロセスです。

「ログイン済みかどうか」の一歩先にある話で、「ログインはしているが、この記事はあなたが書いたものではないので編集できません」といった判断がこれにあたります。

#13で扱うテーマです。

つまり、

認証は「本人確認」、認可は「権限確認」です。

この記事(#10)でユーザーが「誰であるか」を確定できるようにし、#13でその情報をもとに「何をしてよいか」を判断する、という順番で実装を進めていきます。

この記事のゴール

  • 認証ライブラリをセットアップし、Google OAuthでログインできるようにする
  • ログイン状態をUIに反映する(ログイン/ログアウトボタン)
  • ルートレベルとサーバー側の両方で、未ログインユーザーを管理画面から締め出す

なぜ「Google OAuth」を選ぶのか、他にどんな方法があるか

認証の実装方法には、この記事で扱うGoogleログイン(OAuth)以外にも複数の選択肢があります。

まずは全体像を整理し、なぜこの記事がGoogle OAuthを選んだのかを説明しておきます。

認証方式の選択肢

方式特徴
メール/パスワード自分たちで完全にコントロールできる代わりに、パスワードのハッシュ化・リセットメール・メール確認フローなどを自前で設計する必要がある
マジックリンク / OTP(ワンタイムコード)パスワード不要でUXが良い。ただしメールの到達率に依存し、別途メール送信サービス(Resendなど)が必要になることが多い
パスキー(WebAuthn)指紋認証・顔認証などを使う、フィッシング耐性の高い方式。2025〜2026年にかけて主要サービスでの採用が急速に進んでいるが、対応デバイス・ブラウザへの依存がまだ残る
OAuth(Google・GitHub・LINEなど)外部サービスに認証を委譲する方式。実装コストが低く、ユーザーは新たにパスワードを覚える必要がない
エンタープライズSSO(SAML/OIDC)BtoB SaaSで、顧客企業側のID基盤と連携する場合に使う

この記事でGoogle OAuthを選んだ理由は、主に「教材としての説明のしやすさ」です。

メール/パスワード認証は、認証ライブラリを使ってもなお、パスワードリセットやメール送信基盤など自分で設計すべき要素が多く残ります。

OAuthはその大部分をGoogle側に任せられるため、#13で扱う「認証×DBの連携」という本質的なテーマに集中しやすくなります。

また、個人開発やスタートアップの初期フェーズでは、実際にOAuthから始めるケースが多いという実務的な理由もあります。

つまり、「これが唯一の正解だから」ではなく、教材として説明しやすく、かつ実務でもよくある選択だからという位置づけです。

Googleに認証を依存することのリスク

外部のOAuthプロバイダーに依存する方式には、見落とされがちなリスクもあります。

  • 単一障害点になる:Google側で障害が起きると、そのサービスへのログイン手段が丸ごと失われる
  • Googleアカウントを持たない/使いたくないユーザーを排除してしまう:特に法人向けサービスでは、Googleアカウントを業務利用していない企業も少なくない
  • 地域による利用制限:一部の国・地域では、Googleサービス自体へのアクセスが制限されている
  • プロバイダー側のポリシー変更に振り回されるリスク:OAuthアプリの審査基準や利用規約が変更され、対応を迫られることがある

これらは「だからOAuthを使うべきではない」という話ではなく、単一のプロバイダーだけに依存する設計は避けたほうがよい、という考え方です。

対策:複数のログイン手段を用意する

実務では、次のように複数の手段を並行して用意するのが一般的です。

  • OAuthプロバイダーを複数用意する(Google + GitHub、日本向けサービスならLINEログインなど)
  • OAuthに加えて、メール/パスワードなど「外部サービスに依存しない」ログイン手段も1つは用意しておく

幸い、この記事で使うBetter Authはもともとマルチプロバイダー対応を前提に設計されているため、後からsocialProvidersにプロバイダーを追加したり、emailAndPasswordを有効化したりするのは比較的簡単です。

TypeScript
// lib/auth.ts(複数プロバイダーを併用する場合のイメージ)
export const auth = betterAuth({
  database: prismaAdapter(prisma, { provider: "postgresql" }),
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    },
    github: {
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    },
  },
  emailAndPassword: {
    enabled: true,
  },
  plugins: [nextCookies()],
});

このミニブログのようにまずは1つの方法で完成させ、後から用途に応じてプロバイダーを追加していく、という進め方で問題ありません。

この記事では、以降Google OAuthのみを前提に解説を進めます。

どの認証ライブラリを使うか

以前はNext.jsのApp Router向け認証ライブラリとして「Auth.js(NextAuth.js)」が定番でした。

2025年9月にAuth.jsチームはBetter Authの運営元に合流し、Auth.js公式ドキュメントも新規プロジェクトには基本的にBetter Authから始めることを勧めています。

この記事では、現時点での推奨に沿ってBetter Authを使って解説します。

もし別の教材でAuth.js(next-auth)ベースの解説を見かけても、考え方(OAuth・セッション管理・DBへの永続化)自体はほぼ共通なので、置き換えて読み替えられます。

Better Authは、TypeScriptで書かれたオープンソースの認証ライブラリです。

Google・GitHubなどのOAuthプロバイダーや、メール/パスワード認証に対応しており、Prisma・Drizzleなど複数のORMアダプターを公式にサポートしています。

自前でOAuthのフローやセッション管理を実装するのはセキュリティリスクが大きいため、実務では基本的にこうしたライブラリを使うのが一般的です。

Google Cloud ConsoleでOAuthクライアントを作成する

まず、Google側でOAuth認証用の設定を行います。

  1. Google Cloud Consoleにアクセスし、プロジェクトを作成(または既存のものを選択)
  2. 「APIとサービス」→「認証情報」→「認証情報を作成」→「OAuthクライアントID」を選択
  3. アプリケーションの種類は「ウェブアプリケーション」を選択
  4. 「承認済みのリダイレクトURI」に以下を追加
Bash
http://localhost:3000/api/auth/callback/google

作成すると、クライアントIDクライアントシークレットが発行されます。

これらは後で環境変数に設定します。

Better Authをインストールする

Bash
npm install better-auth

Prismaアダプターはbetter-auth本体に同梱されているため、追加のパッケージは不要です(#9で導入した@prisma/client@prisma/adapter-pgpgはそのまま使います)。

その後、扱っていく様々なファイルの役割は以下の図のようになっています。

画像を振り返りながらコードの実装を進めてみてください。

環境変数を設定する

Bash
# .env
BETTER_AUTH_SECRET="ランダムな文字列(後述のコマンドで生成)"
BETTER_AUTH_URL="http://localhost:3000"
GOOGLE_CLIENT_ID="Google Cloud Consoleで取得したクライアントID"
GOOGLE_CLIENT_SECRET="Google Cloud Consoleで取得したクライアントシークレット"

BETTER_AUTH_SECRETは、セッションの署名・暗号化に使われる値です。

以下のコマンドで生成できます。

Bash
openssl rand -base64 32

Prismaスキーマに認証用のテーブルを追加する

Better Authは、ユーザー情報やセッション情報をデータベースに保存する構成が基本です。

schema.prismaに、Better Authが要求する4つのモデル(User / Session / Account / Verification)を追加します。

Better Authにはnpx @better-auth/cli generateという、設定からPrismaスキーマを自動生成してくれるCLIコマンドも用意されています。

ただし執筆時点では、このコマンドが生成するスキーマがPrisma 7の仕様(#9で説明したurlの扱いなど)に対応しきれていない不具合が報告されています。

この記事では、#9で組んだPrisma 7構成と確実に噛み合わせるため、モデルを手動で追加する方法で進めます。

Prisma
// prisma/schema.prisma

model User {
  id            String    @id
  name          String
  email         String    @unique
  emailVerified Boolean   @default(false)
  image         String?
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt

  sessions      Session[]
  accounts      Account[]
}

model Session {
  id        String   @id
  expiresAt DateTime
  token     String   @unique
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  ipAddress String?
  userAgent String?

  userId String
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model Account {
  id                    String    @id
  accountId             String
  providerId            String
  accessToken           String?
  refreshToken          String?
  idToken               String?
  accessTokenExpiresAt  DateTime?
  refreshTokenExpiresAt DateTime?
  scope                 String?
  password              String?
  createdAt             DateTime  @default(now())
  updatedAt             DateTime  @updatedAt

  userId String
  user   User   @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model Verification {
  id         String    @id
  identifier String
  value      String
  expiresAt  DateTime
  createdAt  DateTime? @default(now())
  updatedAt  DateTime? @updatedAt
}

Userモデルのid@default(...)を付けていない点に注目してください。

IDの生成自体はBetter Auth側が行うため、Prisma側でのデフォルト値の指定は不要です。

編集できたら、マイグレーションを実行します。

Bash
npx prisma migrate dev --name add_auth_tables

Auth.jsの設定ファイルを作成する

App Router向けの標準的な構成として、設定をauth.tsに切り出します。

TypeScript
// lib/auth.ts
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { nextCookies } from "better-auth/next-js";
import { prisma } from "@/lib/prisma";

export const auth = betterAuth({
  database: prismaAdapter(prisma, {
    provider: "postgresql",
  }),
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    },
  },
  plugins: [nextCookies()],
});

databaseには、#9で作ったprismaインスタンスをそのまま渡します。

nextCookies()プラグインは、Server Actionsの中でBetter Authがクッキー(セッション)を正しく書き込めるようにするために必須です。

これを入れ忘れると、ログインには成功しているように見えるのにセッションが保存されない、という分かりにくい不具合につながります。

次に、Route Handlerを用意してBetter Authのエンドポイントを公開します。

TypeScript
// app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { GET, POST } = toNextJsHandler(auth);

これで、/api/auth/sign-in/social/api/auth/callback/googleといったエンドポイントが自動的に有効になります。

クライアント用のインスタンスを作成する

ブラウザ側(Client Component)から呼び出すための、軽量なクライアントを用意します。

TypeScript
// lib/auth-client.ts
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient();

同一ドメインで動かす場合、baseURLの指定は省略できます。

別ドメインにAPIを切り出す場合などは、createAuthClient({ baseURL: "..." })のように明示してください。

ログインボタンを作る

Googleログインの開始(signIn.social)は、Server ActionsからではなくClient Componentから直接呼び出すのがBetter Authの基本的な使い方です。

TSX
// components/sign-in-button.tsx
"use client";

import { authClient } from "@/lib/auth-client";

export default function SignInButton() {
  return (
    <button
      onClick={() =>
        authClient.signIn.social({
          provider: "google",
          callbackURL: "/",
        })
      }
      className="rounded-md bg-black px-4 py-2 text-sm text-white"
    >
      Googleでログイン
    </button>
  );
}

ログイン状態をServer Componentで表示する

現在のセッションは、Server Componentの中でauth.api.getSession()を呼び出すことで取得できます。

ログアウトはServer Action経由で行います。

TSX
// app/components/auth-buttons.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import SignInButton from "./sign-in-button";

export default async function AuthButtons() {
  const session = await auth.api.getSession({ headers: await headers() });

  if (session?.user) {
    return (
      <form
        action={async () => {
          "use server";
          await auth.api.signOut({ headers: await headers() });
          redirect("/");
        }}
        className="flex items-center gap-3"
      >
        <span className="text-sm text-muted-foreground">
          {session.user.name}さん、こんにちは
        </span>
        <button type="submit" className="rounded-md border px-3 py-1 text-sm">
          ログアウト
        </button>
      </form>
    );
  }

  return <SignInButton />;
}

auth.api.getSession({ headers: await headers() })は、リクエストヘッダー(クッキーを含む)を渡すことで、そのリクエストのセッションを解決してくれます。

headersを渡し忘れるとnullしか返らないので、忘れずに渡してください。

ルートを保護する

ルートレベルでの「入口」の保護(あくまで補助的なもの)

Next.js 16では、これまでのmiddleware.tsproxy.tsという名前に変わりました(Next.js 15以前を使っている場合はmiddleware.tsのままで構いません)。

ここでBetter AuthのgetSessionCookieを使うと、DBへの問い合わせなしに「セッションクッキーが存在するかどうか」だけを軽量にチェックできます。

TypeScript
// proxy.ts(Next.js 16。Next.js 15以前は middleware.ts)
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";

export async function proxy(request: NextRequest) {
  const sessionCookie = getSessionCookie(request);

  if (!sessionCookie && request.nextUrl.pathname.startsWith("/admin")) {
    return NextResponse.redirect(new URL("/", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/admin/:path*"],
};

⚠️ 重要getSessionCookieは、クッキーが「存在するか」を見ているだけで、そのセッションが本当に有効かどうかをデータベースに問い合わせて検証しているわけではありません。

Better Auth公式ドキュメントも、この関数を使ったチェックを明確に「安全な認証チェックではない」としています。

ここでの役割はあくまで「未ログインのユーザーを、ログイン画面へ気持ちよくリダイレクトさせるUX上の仕掛け」であり、本当のアクセス制御はこの後説明するServer Component・Server Actions側で行う必要があります。

これは、2025年3月と2026年5月にNext.jsのミドルウェア層だけを回避してアクセス制御を突破する脆弱性(CVE-2025-29927、CVE-2026-44574)が実際に報告されたこととも関係しています。

ミドルウェア/プロキシ層はネットワークレベルで迂回されうる、という前提で設計するのが現在の定石です。

本当の防衛ライン:Server Componentでのセッション検証

/admin配下のレイアウトで、auth.api.getSession()を使った確実なチェックを行います。

TSX
// app/admin/layout.tsx
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";

export default async function AdminLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await auth.api.getSession({ headers: await headers() });

  if (!session?.user) {
    redirect("/");
  }

  return <>{children}</>;
}

proxy.ts(またはmiddleware.ts)が「入口」、このレイアウトでのチェックが「実際の防衛ライン」という役割分担になります。

Server Actions内でもログイン状態をチェックする

Server Actionsは、UIのボタンを経由せず直接呼び出されることもあるため、アクション側でも必ずセッションを検証します。

TypeScript
// app/posts/actions.ts
"use server";

import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";

export async function createPost(formData: FormData) {
  const session = await auth.api.getSession({ headers: await headers() });

  if (!session?.user) {
    throw new Error("ログインが必要です");
  }

  const title = formData.get("title") as string;
  const content = formData.get("content") as string;

  await prisma.post.create({
    data: { title, content },
  });

  revalidatePath("/posts");
}

Server Component(auth.api.getSession)・Server Actions(同じくauth.api.getSession)・proxy.tsgetSessionCookie)と、レイヤーごとに使うAPIが異なる点を意識しておくと、後で見返したときに迷いにくくなります。

AuthButtonsをレイアウトに配置する

ここまででAuthButtonsコンポーネントは作りましたが、まだどのページにも配置していません。このままだと、ボタン自体が画面のどこにも表示されないため、「クリックしても何も起きない」のではなく「押すボタンがそもそも存在しない」状態になってしまいます。

忘れずに、共通レイアウトへ組み込んでおきましょう。

TSX
// app/layout.tsx
import AuthButtons from "@/components/auth-buttons";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body className="min-h-full flex flex-col">
        <header className="flex items-center justify-between p-4">
          <h1 className="text-lg font-bold">ミニブログ</h1>
          <AuthButtons />
        </header>
        <main className="flex-1">{children}</main>
      </body>
    </html>
  );
}

見た目は#12で作るHeaderコンポーネントに統合していきますが、この時点では動作確認のために、まずは最小限このように直接レイアウトへ置いておけば十分です。

動作確認

ここまでできたら、実際に動作を確認してみましょう。

  1. npm run devで開発サーバーを起動
  2. トップページで「Googleでログイン」ボタンをクリック
  3. Googleの認証画面が表示され、許可するとアプリにリダイレクトされる
  4. ログイン後、ユーザー名が表示されることを確認
  5. /adminに未ログイン状態でアクセスし、トップページにリダイレクトされることを確認

Prisma Studioを開くと、UserAccountSessionテーブルにデータが保存されていることも確認できます。

Bash
npx prisma studio

ここまで実装してきたコードの内容を整理してみましょう。

つまずきやすいポイント

「await isn't allowed in non-async function」というビルドエラーが出る

ページやレイアウトにauth.api.getSession({ headers: await headers() })のようなawaitを伴うチェックを後から追加した際、関数自体にasyncを付け忘れているケースです。

TypeScript
// ❌ asyncが付いていない
export default function NewPostPage() {
  const session = await auth.api.getSession({ headers: await headers() }); // エラー

// ✅ 関数をasyncにする
export default async function NewPostPage() {
  const session = await auth.api.getSession({ headers: await headers() });

Server Componentの中でawaitを使う場合は、コンポーネント自体をasync functionにする必要があります。

既存のページに後からセッションチェックを追加するときに見落としやすいポイントです。

「Googleでログイン」ボタンを押しても何も起きない

まず、そのボタンが本当に画面に表示されているか確認してください。

AuthButtonsSignInButtonのコンポーネント自体は作成しても、それをどこかのページ・レイアウトに実際に配置し忘れているケースが非常によくあります。

押した瞬間にConsole/Networkタブに何も反応がない場合は、処理が失敗しているのではなく、そもそもボタンが存在しない可能性を疑ってください。

ログインには成功するのに、リロードするとログアウトした状態に戻る

lib/auth.tspluginsnextCookies()を入れ忘れていないか確認してください。

このプラグインがないと、Server Actions経由でのクッキー書き込みが正しく行われません。

auth.api.getSession()が常にnullを返す

headers: await headers()を渡し忘れているケースがほとんどです。

Server ComponentでもServer Actionsでも、必ずリクエストのヘッダーを明示的に渡す必要があります。

npx @better-auth/cli generateを実行したらエラーになった、またはschema.prismaが上書きされてPostモデルが消えた

このCLIは、実行時に既存のschema.prismaを丸ごと上書きするか尋ねてきます。

誤って「はい」を選ぶと、#9で定義したPostモデルが消えてしまいます。

また、Prisma 7環境ではCLIが生成するスキーマの書式が現行の仕様と噛み合わない不具合も報告されているため、この記事では手動でモデルを追加する方法を採用しています。

もしCLIを試す場合は、事前にschema.prismaをコミットしてバックアップを取ってから実行してください。

まとめ

この記事では、以下を実装しました。

  • Google Cloud ConsoleでOAuthクライアントを作成
  • Better Authをセットアップし、Prismaアダプターでセッションをデータベースに保存
  • ログインはClient Componentから、ログアウト・セッション参照はServer Component/Server Actionsから行う構成を実装
  • proxy.ts(またはmiddleware.ts)を「入口のUX」、Server Component/Server Actionsでのauth.api.getSession()を「本当の防衛ライン」として二重に実装

これで、記事の投稿・編集・削除を「ログインしたユーザーだけ」に制限する土台が整いました。

次回の#11 UIコンポーネントライブラリ — shadcn/uiで本格デザインでは、shadcn/uiを使ってログイン画面や管理画面のUIをより実用的なデザインに整えていきます。