Next.js で認証を“アプリ設計”として組み込む入門|セッション/トークン、保護ルート、RBAC の最小実装

はじめに

認証は「ログイン画面を作る」だけの話ではありません。実務で本当に困るのは、ログイン後に始まる設計です。

  • どこでユーザー情報を確定させる?(サーバー?ブラウザ?)
  • ルート保護はどこでやる?(ミドルウェア?各ページ?API?)
  • 権限(RBAC)はどの粒度で持つ?(role だけ?permission まで?)
  • セッションでいく?トークンでいく?(運用、期限、更新、漏洩時の被害)
  • API を守るのは誰?(フロントのガードだけだと破綻する)

このあたりを“後から”足すと、コードは動いても、整合性が壊れます。
だから認証は UI 機能ではなく、アプリ全体の境界(境界線)を決める設計として捉える必要があります。

この記事では Next.js(App Router 前提)で、次の要素を「最小だけど筋の良い形」で通します。

  • セッション/トークンの考え方(なぜそれを選ぶのか)
  • 保護ルート(未ログインを弾く)
  • RBAC の入口(role による権限チェック)
  • “フロントだけで守らない”ための API 防御の置き場

最後に、プロダクションへ寄せるときの拡張ポイントも示します。


座学

1) まず「守る対象」を言語化する

認証・認可を設計するとき、最初に決めるのは「何を守るか」です。よくある対象は次の3つです。

  1. ページ(画面)
    例:/dashboard はログイン必須、/admin は管理者のみ
  2. API(サーバー処理)
    例:POST /api/orders はログイン必須、DELETE は特定権限のみ
  3. データ(リソース)
    例:自分の注文だけ見える、他人の注文は見えない(所有権チェック)

多くの事故は「ページは守ったけど API が開いてる」「role は見てるけど所有権を見てない」で起きます。
ページ・API・データの3層を意識すると、設計がぶれにくいです。


2) 認証(Authentication)と認可(Authorization)を分ける

  • 認証:あなたは誰?(ログイン状態の確定)
  • 認可:あなたは何ができる?(権限・所有権・操作可否)

RBAC は認可の一種です。
「ログインできる」と「その操作ができる」は別物なので、コードも別の関数(責務)に分けるのがコツです。


3) セッション vs トークン:ざっくりの選び方

ここは宗教戦争になりがちですが、入門では “何が嬉しくて何が辛いか” を押さえれば十分です。

セッション(Cookie + サーバー側で状態管理)

  • 良い点
    • ブラウザでは Cookie を持っていればよい(実装がシンプル)
    • トークン管理の複雑さが減る(更新、保存場所、漏洩対策など)
  • 注意点
    • どこかにセッションストアが必要(メモリ/Redis/DB など)
    • スケール時に設計が必要(共有ストア、TTL)

トークン(JWT など:署名された自己完結データ)

  • 良い点
    • サーバー側の状態を持たなくても動く(ステートレス)
    • マイクロサービスや外部連携と相性が良い
  • 注意点
    • 無効化が難しい(漏洩したら期限まで有効になりがち)
    • どこに保存するか(localStorage は危険寄り、Cookie 推奨など)
    • “更新” が実務でだるい(refresh token 設計)

入門としては、「まず Cookie ベースで安全に」 を基本線にすると、失敗しづらいです。
(本番の事情で JWT が必要でも、Cookie に入れて運用する形はよくあります。)


4) Next.js での「守る場所」

Next.js(App Router)での典型的な配置はこうです。

  • middleware.ts:リクエストの入口でページ/ルートを弾く(未ログインのリダイレクトなど)
  • server actions / route handlers(app/api/...:API をサーバーで最終防衛する
  • server component:ユーザー情報を読み、必要なら 403/404 を返す
  • client component:UX の補助(表示制御)に使うが、これだけで守らない

ポイントは1つ:
「UX の都合でフロントでも隠していい。でも最終的にサーバーで拒否する」
これさえ守れば、事故は激減します。


5) RBAC の入口:role だけでも“設計の芯”になる

RBAC は本来、role→permission のマッピングまで育てられますが、入門ではまず role で十分です。

  • user:一般
  • staff:運用
  • admin:管理者

そして、役割を “文字列”で散らさず、アプリのどこかに定義を集約します。
権限が散らばると、将来の改修が地獄になります。


ハンズオン

ここでは「超ミニマムな擬似ログイン」を作り、保護ルート + RBAC まで一気に通します。
外部認証(OAuth など)は使いません。目的は “設計の型” を作ることです。

0) 前提:Next.js App Router プロジェクト

npx create-next-app@latest nextjs-auth-rbac
cd nextjs-auth-rbac
npm run dev

1) まずは“疑似ユーザーDB”を用意する

app/_lib/users.ts

export type Role = "user" | "admin";

export type User = {
  id: string;
  email: string;
  role: Role;
};

// デモ用:本来はDBに置く
export const demoUsers: Record<string, User> = {
  "user@example.com": { id: "u1", email: "user@example.com", role: "user" },
  "admin@example.com": { id: "a1", email: "admin@example.com", role: "admin" },
};

2) Cookie に「セッション」を入れる(超ミニマム)

ここでは “本物のセッションストア” ではなく、Cookie に userId/role を入れて動かす簡易版にします。
※本番では署名や暗号化、セッションストアなどに発展させます(後述)。

app/_lib/auth.ts

import { cookies } from "next/headers";
import type { Role, User } from "./users";
import { demoUsers } from "./users";

const COOKIE_NAME = "demo_session";

type Session = {
  userId: string;
  email: string;
  role: Role;
};

export function setDemoSession(user: User) {
  const session: Session = { userId: user.id, email: user.email, role: user.role };
  // デモ:JSONをそのままCookieに(本番は署名/暗号化が必須)
  cookies().set(COOKIE_NAME, JSON.stringify(session), {
    httpOnly: true,
    sameSite: "lax",
    path: "/",
  });
}

export function clearDemoSession() {
  cookies().set(COOKIE_NAME, "", { path: "/", maxAge: 0 });
}

export function getDemoSession(): Session | null {
  const raw = cookies().get(COOKIE_NAME)?.value;
  if (!raw) return null;
  try {
    return JSON.parse(raw) as Session;
  } catch {
    return null;
  }
}

export function requireLogin(): Session {
  const session = getDemoSession();
  if (!session) {
    // App Routerではページ側で redirect/notFound を使うことが多いが、
    // ここは「未ログインは null ではなく例外」という責務にしておく
    throw new Error("UNAUTHORIZED");
  }
  return session;
}

export function requireRole(required: Role): Session {
  const session = requireLogin();
  if (session.role !== required) {
    throw new Error("FORBIDDEN");
  }
  return session;
}

export function findDemoUserByEmail(email: string) {
  return demoUsers[email] ?? null;
}

3) ログインフォーム(Server Action)で Cookie をセット

Server Action を使うと、フォーム送信→サーバー処理→Cookie 設定が自然に書けます。

app/login/page.tsx

import { redirect } from "next/navigation";
import { findDemoUserByEmail } from "../_lib/auth";
import { setDemoSession } from "../_lib/auth";

async function loginAction(formData: FormData) {
  "use server";
  const email = String(formData.get("email") ?? "");

  const user = findDemoUserByEmail(email);
  if (!user) {
    // デモ:雑に弾く。本番はエラー表示を整える
    throw new Error("INVALID_CREDENTIALS");
  }

  setDemoSession(user);
  redirect("/dashboard");
}

export default function LoginPage() {
  return (
    <main style={{ padding: 24 }}>
      <h1>Login(デモ)</h1>
      <p>user@example.com または admin@example.com を入力してください。</p>

      <form action={loginAction} style={{ display: "grid", gap: 12, maxWidth: 420 }}>
        <input name="email" placeholder="email" />
        <button type="submit">ログイン</button>
      </form>
    </main>
  );
}

4) ログアウト(Server Action)

app/logout/page.tsx

import { redirect } from "next/navigation";
import { clearDemoSession } from "../_lib/auth";

async function logoutAction() {
  "use server";
  clearDemoSession();
  redirect("/");
}

export default function LogoutPage() {
  return (
    <main style={{ padding: 24 }}>
      <h1>Logout</h1>
      <form action={logoutAction}>
        <button type="submit">ログアウト</button>
      </form>
    </main>
  );
}

5) 保護ルート:ダッシュボードを“ログイン必須”にする

ここで初めて「アプリ設計っぽさ」が出ます。
ログインしていないなら表示しないを、サーバー側で確定させます。

app/dashboard/page.tsx

import { redirect } from "next/navigation";
import { getDemoSession } from "../_lib/auth";

export default function DashboardPage() {
  const session = getDemoSession();
  if (!session) redirect("/login");

  return (
    <main style={{ padding: 24 }}>
      <h1>Dashboard</h1>
      <p>ようこそ、{session.email}</p>
      <p>role: {session.role}</p>
      <p>
        <a href="/admin">/admin</a>(adminのみ)
      </p>
      <p>
        <a href="/logout">ログアウト</a>
      </p>
    </main>
  );
}

この段階で、ページはサーバーで守るという基礎ができました。
(クライアントの表示制御だけに頼らないのが重要です。)


6) RBAC:/admin は admin だけ

app/admin/page.tsx

import { redirect } from "next/navigation";
import { getDemoSession } from "../_lib/auth";

export default function AdminPage() {
  const session = getDemoSession();
  if (!session) redirect("/login");
  if (session.role !== "admin") {
    // 403ページを作るのもあり。ここでは簡易にダッシュボードへ。
    redirect("/dashboard");
  }

  return (
    <main style={{ padding: 24 }}>
      <h1>Admin</h1>
      <p>管理者だけが見られるページです。</p>
    </main>
  );
}

ここまでで、「ログイン必須」「管理者のみ」の入口ができました。
でも、これだけだと “ページは守れても API が開いてる” 問題が残ります。


7) API も守る:Route Handler で最終防衛

app/api/secret/route.ts

import { NextResponse } from "next/server";
import { getDemoSession } from "../../_lib/auth";

export function GET() {
  const session = getDemoSession();
  if (!session) {
    return NextResponse.json({ error: "unauthorized" }, { status: 401 });
  }
  if (session.role !== "admin") {
    return NextResponse.json({ error: "forbidden" }, { status: 403 });
  }
  return NextResponse.json({ message: "admin secret data", by: session.email });
}

そして管理画面から叩いてみます(UX用。防御は API 側で確定済み)。

app/admin/SecretClient.tsx

"use client";

import { useState } from "react";

export default function SecretClient() {
  const [data, setData] = useState<string>("");

  const load = async () => {
    const res = await fetch("/api/secret");
    const json = await res.json();
    setData(JSON.stringify(json));
  };

  return (
    <div style={{ marginTop: 16 }}>
      <button onClick={load}>/api/secret を叩く</button>
      <pre style={{ marginTop: 12 }}>{data}</pre>
    </div>
  );
}

app/admin/page.tsx に組み込み

import SecretClient from "./SecretClient";
<SecretClient />

これで、

  • admin なら message が返る
  • user なら 403
  • 未ログインなら 401
    というふうに、API が本当に守られている状態が確認できます。

8) middleware で“入口の体験”を整える(任意だが強い)

ページ側の redirect でも守れますが、入口でまとめて弾くと一貫性が出ます。

middleware.ts(プロジェクトルート)

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

const COOKIE_NAME = "demo_session";

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  const session = req.cookies.get(COOKIE_NAME)?.value;

  // ログイン不要
  if (pathname === "/" || pathname.startsWith("/login")) return NextResponse.next();

  // ダッシュボード配下はログイン必須
  if (pathname.startsWith("/dashboard") || pathname.startsWith("/admin")) {
    if (!session) {
      const url = req.nextUrl.clone();
      url.pathname = "/login";
      return NextResponse.redirect(url);
    }

    // admin配下は role チェック(デモ:CookieをJSONとして読む)
    if (pathname.startsWith("/admin")) {
      try {
        const parsed = JSON.parse(session) as { role?: string };
        if (parsed.role !== "admin") {
          const url = req.nextUrl.clone();
          url.pathname = "/dashboard";
          return NextResponse.redirect(url);
        }
      } catch {
        const url = req.nextUrl.clone();
        url.pathname = "/login";
        return NextResponse.redirect(url);
      }
    }
  }

  return NextResponse.next();
}

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

middleware は「入口で弾く」ので、
ページごとに同じコードを書く量が減り、ポリシーが揃います。


まとめ

認証を“アプリ設計”として扱うときの要点は、UI より先に 境界 を作ることです。

  • 守る対象は「ページ・API・データ」の3層で考える
  • フロントの表示制御だけに頼らず、サーバーで最終拒否する
  • 保護ルートは middleware / server component / route handler の組み合わせで作れる
  • RBAC はまず role だけでも入口になる(定義を散らさない)
  • 「認証(誰か)」と「認可(何ができるか)」を分ける

ハンズオンでは、Cookie を使った超ミニマムなセッションで、
ログイン → 保護ルート → 管理者のみ → API の最終防衛まで通しました。

この形をベースにしておけば、外部認証(OAuth)や DB、セッションストア、permission 制御などを後から追加しても、壊れにくい構造に育てられます。


投稿日

カテゴリー:

投稿者:

タグ:

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です