はじめに
認証は「ログイン画面を作る」だけの話ではありません。実務で本当に困るのは、ログイン後に始まる設計です。
- どこでユーザー情報を確定させる?(サーバー?ブラウザ?)
- ルート保護はどこでやる?(ミドルウェア?各ページ?API?)
- 権限(RBAC)はどの粒度で持つ?(role だけ?permission まで?)
- セッションでいく?トークンでいく?(運用、期限、更新、漏洩時の被害)
- API を守るのは誰?(フロントのガードだけだと破綻する)
このあたりを“後から”足すと、コードは動いても、整合性が壊れます。
だから認証は UI 機能ではなく、アプリ全体の境界(境界線)を決める設計として捉える必要があります。
この記事では Next.js(App Router 前提)で、次の要素を「最小だけど筋の良い形」で通します。
- セッション/トークンの考え方(なぜそれを選ぶのか)
- 保護ルート(未ログインを弾く)
- RBAC の入口(role による権限チェック)
- “フロントだけで守らない”ための API 防御の置き場
最後に、プロダクションへ寄せるときの拡張ポイントも示します。
座学
1) まず「守る対象」を言語化する
認証・認可を設計するとき、最初に決めるのは「何を守るか」です。よくある対象は次の3つです。
- ページ(画面)
例:/dashboardはログイン必須、/adminは管理者のみ - API(サーバー処理)
例:POST /api/ordersはログイン必須、DELETEは特定権限のみ - データ(リソース)
例:自分の注文だけ見える、他人の注文は見えない(所有権チェック)
多くの事故は「ページは守ったけど 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 制御などを後から追加しても、壊れにくい構造に育てられます。
コメントを残す