1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js業務アプリの認証・認可(Better Auth実践編)

1
Last updated at Posted at 2026-03-14

🎯 この記事の対象者

認証機能は作れたけれど、、、

  • Middlewareが重い
  • 権限チェックの漏れが不安
  • 画面遷移時のガタつき(Layout Shift)

そんな実務上の課題を解決する構成案を紹介します。

何か誤りなどあれば、お気軽にコメントしてください。

認証機能は特にデリケートです。
正確な記事を心がけていますが、私自身も探求中の未熟者なのでご容赦ください🙇

✅ 詳細な導入手順を知りたい方へ
本記事は単体で完結していますが、Next.jsプロジェクトの作成から、Better Auth導入までの詳細な手順を知りたい方は、ハンズオン記事もあわせてご覧ください。

🚀 この記事で学べること

  • Better Authの実務での利用
  • 認証・認可の多層防衛アーキテクチャ
  • クライアントUIでの権限制御

⚙️ この記事の環境

  • 認証: Better Auth
  • DB: Neon DB (Serverless PostgreSQL)
  • ORM: Drizzle ORM
  • UI: shadcn/ui
  • Next.js: 16.1.6

🔎 アプリのイメージ

✅ ログイン画面
image.png

✅ 管理者権限(マスタメンテ可能)
image.png

✅ 一般権限(マスタメンテ不可)
image.png

実際に触ってみるのが一番イメージが湧くと思います!
デモサイトを用意しました。

1.全体の構成

✅ ディレクトリ構造
(認証・認可関連だけを抜粋)

src/
├── app/
│   ├── (protected)/     # Route Group(認証・認可必須エリア)
│   │   ├── (各種業務)   # 認証者用の各種業務用のページなど
│   │   └── page.tsx     # ログイン後トップ
│   │
│   ├── login/           # ログイン(認証不要)
│   │   ├── _components  # ログインページ用コンポーネント
│   │   └── page.tsx     # ログインページ
│   │
│   ├── signup/          # サインアップ(ユーザー登録:認証不要)
│   │   ├── _components  # サインアップ用コンポーネント
│   │   └── page.tsx     # サインアップ用ページ
│   │
│   └── api/auth/[...all]/route.ts # Better Auth API
│
├── components/
│   ├── NavigationServer.tsx   # ナビゲーション用サーバーコンポーネント
│   └── Navigation.tsx         # ナビゲーション用クライアントコンポーネント
│
├── db/
│   └── schema.ts        # Drizzleスキーマ
│
├── lib/
│   ├── auth.ts          # Better Auth サーバー設定
│   ├── auth-client.ts   # Better Auth クライアント設定
│   └── auth-guard.ts    # 厳格な検証用ガード関数
│
└── proxy.ts             # 薄いプロキシ (Thin Proxy)

「爆速の門番(proxy.ts)」と「厳格な本人確認(requireSession)」を組み合わせた、Next.jsのポテンシャルを最大限に引き出すアーキテクチャを解説します。


🔤 デモアプリのコード


2.Better Authにより作成されるDBスキーマ

Better Authは認証情報の保存にデータベースを使用します。
今回のプロジェクトでは Drizzle ORM を利用していますが、Better AuthのDrizzleアダプターが公式でサポートされているため、スキーマ定義は非常にシンプルに構築できます。
(CLIコマンドで自動生成させることが基本になると思います。)

schema.ts 内に、以下の4つの主要テーブルを定義します。

✅ schema.ts (抜粋)

src/db/schema.ts
import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core";

// 1. user: ユーザーの基本情報(プラグインにより role 等が拡張される!)
export const user = pgTable("user", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  emailVerified: boolean("email_verified").default(false).notNull(),
  image: text("image"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
  // adminプラグインを有効にしたことで追加される拡張カラム
  role: text("role"),
  banned: boolean("banned").default(false),
  banReason: text("ban_reason"),
  banExpires: timestamp("ban_expires"),
});

// 2. session: アクティブなログインセッション情報を管理
export const session = pgTable("session", {
  id: text("id").primaryKey(),
  expiresAt: timestamp("expires_at").notNull(),
  token: text("token").notNull().unique(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").notNull(),
  ipAddress: text("ip_address"),
  userAgent: text("user_agent"),
  userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
});

// 3. account: OAuth等の外部プロバイダー連携情報
export const account = pgTable("account", { /* ... */ });

// 4. verification: メール確認やパスワードリセット用のトークン管理
export const verification = pgTable("verification", { /* ... */ });

ポイント:
Better Authの admin() プラグインを有効にすると、role カラムなどを使用した認可ロジックが即座に利用可能になります。


3.ログインページとCookieの作成

ログイン画面はクライアントコンポーネントとして実装し、Better Auth が提供する クライアントSDK を利用してAPIと通信します。

✅ LoginForm.tsx (抜粋)

src/app/login/_components/LoginForm.tsx
"use client";
import { signIn } from "@/lib/auth-client"; // Better AuthのクライアントSDK
import { useRouter } from "next/navigation";

export function LoginForm() {
  const router = useRouter();

  async function onSubmit(values: z.infer<typeof formSchema>) {
    // 1. クライアントSDKの関数を呼び出し
    await signIn.email(
      {
        email: values.email,
        password: values.password,
      },
      {
        onSuccess: () => {
          // 2. 成功するとブラウザにセッションのCookieがセットされる
          // 3. 画面を再描画しつつ、トップページへ遷移
          router.refresh(); 
          router.push("/");
        },
        onError: (ctx) => {
          toast.error("ログインに失敗しました: " + ctx.error.message);
        },
      }
    );
  }

  return (
    // フォームUI要素...
  );
}

✅ Better Auth クライアントSDKが呼ばれた瞬間
(ログイン画面で「ログインボタン」をクリックした瞬間)

await signIn.email が App Router の仕組みで 自動的に
POST /api/auth/sign-in/emailへ変換されている

image.png

Cookie発行の仕組み:
signIn.email メソッドが成功すると、Better AuthのバックエンドがDBの session テーブルにレコードを作成し、ブラウザに対して Set-Cookie ヘッダーを返却します。
これによってブラウザに __Secure-better-auth.session_token という名前のCookieが保存されます。

image.png


4.ログイン成功のコールバックの仕組み

✅ LoginForm.tsx (抜粋)

src/app/login/_components/LoginForm.tsx
// 1. クライアントSDKの関数を呼び出し
await signIn.email(
  {
    email: values.email,
    password: values.password,
  },
  {
    onSuccess: () => {
      // 2. 成功するとブラウザにセッションのCookieがセットされる
      // 3. 画面を再描画しつつ、トップページへ遷移
      router.refresh(); 
      router.push("/");
    },
    onError: (ctx) => {
      toast.error("ログインに失敗しました: " + ctx.error.message);
    },
  }
);

✅ イベントループとバッチ処理の仕組み
JavaScriptの「コールスレッド(同期処理)」が動いている間、ブラウザはネットワークリクエストをすぐには飛ばしません。

  • 関数実行: onSuccess 内のコードが上から順に実行される
  • 状態の予約: router.refresh() と router.push("/") が「次に行うべきタスク」としてNext.js内部のキュー(待ち行列)に登録される
  • 同期処理の完了: onSuccess の関数を抜ける
  • 非同期タスクの開始: ここで初めて、Next.jsが「さて、命令が2つ溜まっているな。今の最新状態(Cookie)を使って、キャッシュを無視して / を取りに行こう」と判断し、1つのリクエストとしてサーバーに投げます

成功時(onSuccess)の
router.refresh()router.push("/")
は同時にサーバーへ送信されます。


5.認証・認可の多層防衛アーキテクチャ

本記事では、以下の「多層防御」による防衛ラインを構築します。

スピード(体感速度)と安全性の両立がテーマです。

  • 防衛ライン①proxy.ts (ミドルウェア) による高速なルーティング制御(Thin Proxy)
  • 防衛ライン② : Route Groups (protected) によるディレクトリの保護
  • 防衛ライン③ : 認可状態を確認しナビゲーションへ表示されるメニューの切り替え表示
  • 防衛ライン④ : サーバーコンポーネントでの厳格なセッション検証
  • 防衛ライン⑤ : サーバーアクション(API層)での認可(Role)強制

6.防衛ライン①:proxy.ts による「薄いプロキシ」

Next.js 16 (Canary) から middleware.tsproxy.ts へ変更されました。

ここでページ遷移のたびにDBへセッション問い合わせを行うと、特にEdgeネットワーク環境では大きな遅延が発生してしまいます。そこで、プロキシ層では「セッションCookieの有無」のみを高速判定する Thin Proxy(薄いプロキシ) を採用し、爆速のページリダイレクトを実現します。

✅ /src/proxy.ts

import { NextRequest, NextResponse } from "next/server";

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;

  const publicPaths = ["/login", "/signup"];
  const isPublicPath = publicPaths.some((path) => pathname.startsWith(path));

  // Better Auth の Cookie 名は 開発時とデプロイ後で変化する
  const sessionCookie =
    request.cookies.get("better-auth.session_token") ||
    request.cookies.get("__Secure-better-auth.session_token");

  const isAuthenticated = !!sessionCookie;

  // ルートへのアクセス:Cookieの有無だけで判定し、詳細は遷移先に委ねる
  if (pathname === "/") {
    const target = isAuthenticated ? "/dashboard" : "/login";
    return NextResponse.redirect(new URL(target, request.url));
  }

  // 未ログイン状態(Cookieなし)で保護ページへ行こうとした場合のみリダイレクト
  if (!isAuthenticated && !isPublicPath) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  // ログイン済み(Cookieあり)で /login へ行くケースの「逆流防止」はここでは行わない。
  // ここでリダイレクトすると、偽装Cookie時に無限ループの原因となるため。
  return NextResponse.next();
}

export const config = {
  matcher: [
    "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)",
  ],
};

MiddlewareはEdge Runtimeという軽量な環境で動きます。
ここから正規の認証確認(DBアクセスし、メールアドレスとパスワードが合致しているか確認)は、技術的に制限があったり、コネクションの確立に時間がかかったりすることが多いです。

ここでは軽量な判定(Thin Proxy)にとどめ、厳密な認証・認可判定は各ページで行うようにします。
これにより、判定がメモリ内で完結し、ミリ秒単位で画面遷移が行われます。


7.偽装Cookie

プロキシ層を軽量化した代償として「偽装Cookieによる突破」の懸念が生じます。

✅ CookieのValueを適当に作成
image.png

✅ マスタメンテナンスページへ直接アクセス

  • GETは /master/product
  • しかし偽装Cookieでは各ページに設置されている認証・認可判定が突破できない
  • loginページへリダイレクトされる

image.png

✅ マスタページのレンダリング前の認可チェック requireAdmin()
(次章以降で詳細を説明します)

src/app/(protected)/master/product/page.tsx
import { requireAdmin } from "@/lib/auth-guard";

export default async function Page({
  searchParams,
}: {
  searchParams: Promise<{ q?: string; page?: string }>;
}) {
  // Admin権限がなければ、レンダリングせずに終了
  await requireAdmin();
  return (
  ....
  • マスタメンテなど管理者用のページは requireAdmin() で認可チェック
  • 受注ページなど一般用のページは requireSession() で認証のみチェック

✅ CookieのValue とDBに保存される Session ID

署名の付与(Cookie側): ブラウザにある長い値は、実は「セッションID本体」に「サーバー側での署名」がくっついた状態です。これにより、Cookieが物理的に改ざんされていないかをサーバーが即座にチェックできます。

image.png

この長さの文字列を偶然突破は数学的にありえないでしょう

ハッシュ化(DB側): サーバーはCookieを受け取ると、その値を SHA-256 などのアルゴリズムでハッシュ化します。ハッシュ化後の値がDBに書き込まれます。

image.png

万が一DBから情報漏洩してもハッシュ化前の値は復元できないので、セッションハイジャックの心配がありません


8.防衛ライン②&④:Route Groupsと厳密な検証ガード

認証が必要なアプリ内部((protected) フォルダ配下)の layout.tsxpage.tsx において、データベースと照合する厳密な検証を二段構えで行います。

✅ Better Auth の設定 (lib/auth.ts)
ここでは admin プラグインを導入することで、面倒な型拡張なしにRole管理を可能にします。

src/lib/auth.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { admin } from "better-auth/plugins";

import { db } from "@/db/drizzle";
import { schema } from "@/db/schema";

export const auth = betterAuth({
  // 1) レート制限 — ブルートフォース攻撃対策
  // 本番環境では storage: "database" または customStorage で Redis を推奨
  rateLimit: {
    storage: "memory",
    customRules: {
      "/sign-in/email": { window: 60, max: 5 }, // 60秒間に5回まで
    },
  },

  // 3) 認証情報は `drizzle` 経由の `Postgres` へ格納する
  database: drizzleAdapter(db, {
    provider: "pg",
    schema,
  }),

  // 4) 認証はemail + パスワードのみとする
  emailAndPassword: {
    enabled: true,
  },

  // 5) セッションの有効期限を設定する
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7日 (seconds)
    updateAge: 60 * 60 * 24 * 1, // 1日ごとに有効期限を更新
  },

  // 6) プラグインを設定する
  plugins: [
    admin(), // 管理者機能プラグイン
    nextCookies(), // 常に配列の最後に配置
  ],
});

✅ ガード関数の定義 (lib/auth-guard.ts)
サーバーコンポーネント内で呼び出すことで、初めてDBへのセッション照会を行います。

import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";

// 認証ガード(ログインしているか)
export async function requireSession() {
  const session = await auth.api.getSession({
    headers: await headers(), // 最新のヘッダーを渡し、Dynamic Renderingを強制
  });

  if (!session) redirect("/login");
  return session;
}

// 認可ガード(管理者権限を持っているか)
export async function requireAdmin() {
  const session = await requireSession();
  
  // 管理者以外はトップページへ強制送還
  if (session.user.role !== "admin") {
    redirect("/");
  }
  return session;
}

このガード関数を app/(protected)/layout.tsx や各ページの先頭で呼び出します。

レイヤー 役割 判定基準 速度
プロキシ層 (proxy.ts) 高速な交通整理 Cookie の有無 最速
コンポーネント層 (auth-guard) 厳格なデータ保護 DB照会 標準

9.防衛ライン③:認可状態とナビゲーションメニューの切り替え

useSession のようなクライアント用フックを使用してナビゲーションの表示制御を行うと、ローディング中の「UIのちらつき(Layout Shift)」が発生してしまいます。
Next.js Server Componentsの強みを活かし、サーバー側で認可情報(セッション)を取得して、表示コンポーネントへPropsとして渡す設計(コロケーション・バケツリレー)がベストプラクティスです。

✅ src/components/NavigationServer.tsx (サーバーコンポーネント)

import { requireSession } from "@/lib/auth-guard";
import NavLinks from "./NavLinks";

export default async function NavigationServer() {
  // 1. サーバー側で厳格にセッション情報を取得(ここでDB通信)
  const session = await requireSession();
  
  // 2. 受け取ったオブジェクトから権限(Role)を判定
  const isUserAdmin = session.user.role === "admin";
  const userName = session.user.name;

  return (
    <nav>
      {/* 3. クライアントコンポーネントへPropsとして情報を渡す */}
      <Navigation isAdmin={isUserAdmin} />
      <span>{userName}</span>
    </nav>
  );
}

✅ Navigation は受け取った Props に基づいて描画するだけ
これにより、画面起動時に権限判定のローディング待ちはなくなり、ユーザーは一瞬で最適なナビゲーションメニューを利用できます。


10.防衛ライン⑤:サーバーアクション層での絶対防衛

UIからの表示ガードだけでは、クライアントから直接APIや Server Actions を叩かれた場合に脆弱性が残ります。
ここでは「マスタメンテナンス機能」の商品登録処理を例に、サーバーアクション側での確実な防衛手法を解説します。

src/app/(protected)/master/product/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { ZodError } from "zod";

import { 商品Input, 商品Model } from "@/db/model/商品Model";
import { 商品Repository } from "@/db/repository/商品Repository";
import { requireAdmin } from "@/lib/auth-guard";

export async function save商品(data: 商品Input, isEdit: boolean) {
  // ①認可判定
  await requireAdmin();

  try {
    // ②入力検証
    // クライアントから情報を信頼しない。
    //(data: 商品Input としているが、実際に受け取るのはただのJSON)
    // サーバーサイドとして、受け取った情報が商品Modelとして適切か確認する
    const validated = 商品Model.parse(data);
    if (isEdit) {
      await 商品Repository.Update(
        validated.商品CD,
        validated.version,
        validated,
      );
    } else {
      const result = await 商品Repository.Insert(validated);
      if (result.length === 0) {
        return { success: false, error: "商品が既に存在します" };
      }
    }
    // 現在表示中の商品情報キャッシュの破棄
    revalidatePath("/master/product");
    return { success: true };
  } catch (e: unknown) {
    console.error("Delete Error:", e);

    // Zodのバリデーションエラー
    if (e instanceof ZodError) {
      return {
        success: false,
        error: "入力内容に不備があります。画面の指示に従ってください。",
      };
    }

    // 楽観的排他ロックの失敗など、
    // Error インスタンスであれば、そのメッセージをフロントに返す
    if (e instanceof Error) {
      return {
        success: false,
        error: e.message,
      };
    }
    // 予期せぬエラー(ネットワーク切断など)の場合のフォールバック
    return {
      success: false,
      error: "予期せぬエラーが発生しました。時間をおいて再度お試しください。",
    };
  }
}

「①認可判定」と「②入力検証」この2ステップを踏むことで、ブラウザ経由以外で不正なリクエストを送られても確実に防ぐことができます。


11.クライアント(UI)側の実装におけるベストプラクティス

✅ UI描画でのバケツリレー(Layout → Client Component)
クライアント側("use client")で useSession 等のフックを用いると、ローディング(UIのちらつき)が発生する原因になります。これを防ぐための推奨設計パターンは、Server Component(page.tsxなど)で一括して情報を取得し、Client Component には必要な Prop として渡す方法です。

src/components/NavigationServer.tsx
// Server Side (レイアウトでDB一括フェッチ)
import { requireSession } from "@/lib/auth-guard";
import Navigation from "./Navigation";

export default async function NavigationServer() {
  const session = await requireSession();
  const isUserAdmin = session?.user.role === "admin";

  return (
    ..中略..
    {/* ナビゲーションは管理者とそれ以外で分ける  */}
    <Navigation isAdmin={isUserAdmin} />
 );
}
src/components/Navigation.tsx
// Client Side (フックは呼ばない・描画に専念)
"use client";
export default function Navigation({ isAdmin }: NavigationProps) {
  return (
  );
}

これにより、画面起動時の無駄なローディングがなくなり、不要なフロントエンドからAPIへのフェッチ通信も削減されます。


🚀 まとめ

Next.js (App Router) 時代の「業務アプリケーションの認証・認可」の、僕からのお勧めは以下の通りです。

  1. ライブラリ選定: 型安全の恩恵と拡張性の高さから、「Better Auth」が有力な選択肢
  2. 多層防御とスピードの両立: 『Thin Proxy層(Cookie有無による超高速リダイレクト)』 と 『Server Components のガードによる厳格なDB照合』 の組み合わせ
  3. サーバーアクションの堅牢化: クライアントからのデータ通信は一切信用せず、アクション実行前に必ず requireAdmin 等の権限チェックと Zod による強制バリデーション
  4. UXの向上: 描画時のちらつき(Layout Shift)を防ぐため、セッション情報の取得などデータフェッチはサーバーコンポーネント側に寄せ、クライアントにはPropsで渡す

認証・認可は一度仕組みを作ってしまえば、日々の開発では requireSession()requireAdmin() を呼び出すだけで安全が担保されます。皆様のプロダクト開発において、この「多層防衛アーキテクチャ」が少しでも参考になれば幸いです!

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?