認証とセッション — Auth.js v5 / OAuth / JWT
JWT と DB session の選択基準、Auth.js v5 セットアップ、Credentials と OAuth プロバイダ、RSC・Client・Server Action・middleware の4箇所でセッションを読むパターン。
31章でパフォーマンス測定と改善を扱いました。本章ではフルスタックアプリのもう一つの最初の入り口、認証とセッション を扱います。「会員登録 / ログイン / 権限別ページ」 はほぼすべての実アプリで直面する問題で、設計を一度誤るとセキュリティ事故に繋がる領域です。
本章の成果物は次の34章(フルスタック Todo キャプストーン)でそのまま使われます。そして27章(Server Actions とフォーム)で見た Server Action の中に権限検証をどう挟むか、30章(Playwright)の storageState がどのセッションモデルの上で動くかも、本章で答えていきます。
セッションモデルの2系統 #
セッションを作る方式は大きく2つあります。JWT (JSON Web Token) と DB session です。
JWT — stateless #
サーバーがユーザー情報を含むトークンに署名してクライアントに渡します。クライアントは毎回そのトークンを送り、サーバーは署名を検証してユーザーを識別します。
1. ログイン成功 → サーバーが JWT を発行 (payload: { userId, exp })
2. クライアントが JWT を cookie または localStorage に保存
3. リクエストごとに JWT を添付 → サーバーが署名を検証 → userId を抽出
4. サーバーにはセッションストレージがない (stateless)長所: サーバーにセッションストレージが不要、水平スケーリングが容易。
短所: トークンを強制的に無効化しづらい(期限切れまで待つか deny list が必要)、トークンが大きくなると毎回のリクエストコストが増えます。
DB Session — stateful #
サーバーはセッション ID だけを cookie で渡し、実際のユーザー情報は DB に保存します。
1. ログイン成功 → サーバーがセッション row を DB に作成 (id, userId, expires)
2. セッション id を cookie でクライアントに渡す
3. リクエストごとに cookie を添付 → サーバーが DB からセッションを照会 → userId を抽出
4. ログアウトまたは強制失効時に DB row を削除して即座に無効化が可能長所: 即時無効化が可能、セッション情報を自由に拡張可能。
短所: リクエストごとに DB 参照(キャッシュで緩和可能)、セッションストレージの運用負担。
選択基準 #
| 状況 | 推奨モデル |
|---|---|
| マイクロサービス、複数ドメイン SSO | JWT |
| 単一アプリ、即時ログアウトが必須 | DB session |
| OAuth のみで自前のパスワードは扱わない | どちらでも OK |
| シンプルなフルスタックアプリ(本書のキャプストーン) | DB session(Auth.js v5 + Drizzle adapter 推奨) |
本章の例は DB session を基本に扱います。即時無効化とセッション拡張の価値は学習段階でより頻繁に必要になるからです。JWT が必要な場合は、同じ Auth.js の中でオプション変更だけで切り替えられます。
Auth.js v5 セットアップ #
Next.js 15 App Router と整合する認証ライブラリはいくつかありますが、最も普遍的な選択肢が Auth.js v5(旧 NextAuth.js)です。
pnpm add next-auth@beta(M5 時点で v5 が stable かどうかによってバージョン指定が変わる可能性があります。本文の例は v5 の API 形に基づきます。)
auth.ts — 単一の入口 #
Auth.js v5 は設定とヘルパーを 1 つのファイルに集めます。
import NextAuth from 'next-auth';
import GitHub from 'next-auth/providers/github';
import Credentials from 'next-auth/providers/credentials';
import { DrizzleAdapter } from '@auth/drizzle-adapter';
import { db } from '@/lib/db';
import { verifyPassword } from '@/lib/password';
export const { auth, handlers, signIn, signOut } = NextAuth({
adapter: DrizzleAdapter(db),
session: { strategy: 'database' }, // JWT なら 'jwt'
providers: [
GitHub({
clientId: process.env.AUTH_GITHUB_ID!,
clientSecret: process.env.AUTH_GITHUB_SECRET!,
}),
Credentials({
credentials: {
email: { label: 'メールアドレス', type: 'email' },
password: { label: 'パスワード', type: 'password' },
},
async authorize(credentials) {
const user = await db.users.findByEmail(credentials.email as string);
if (!user) return null;
const ok = await verifyPassword(credentials.password as string, user.passwordHash);
return ok ? { id: user.id, email: user.email, name: user.name } : null;
},
}),
],
pages: {
signIn: '/login',
},
});要点。
adapterで DB アダプタを接続(Drizzle / Prisma / Supabase など選択)。session.strategyで JWT / DB を選択。providersに OAuth(GitHub / Google / …)と Credentials を登録。- 返された
auth/handlers/signIn/signOutを他のファイルで import して使います。
API ルートハンドラ #
export { GET, POST } from '@/auth';(Auth.js v5 の handlers が { GET, POST } を露出します。上はそれをそのままルートとして公開する1行のハンドラです。)
Middleware で保護されたパス #
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
export default auth(req => {
const isLoggedIn = !!req.auth;
const isProtected = req.nextUrl.pathname.startsWith('/dashboard');
if (isProtected && !isLoggedIn) {
return NextResponse.redirect(new URL('/login', req.nextUrl));
}
});
export const config = {
matcher: ['/dashboard/:path*'],
};auth が middleware のシグネチャも併せて扱います。req.auth に現在のセッションが入ります。
Credentials provider — パスワードを自前で扱う #
OAuth だけで足りるアプリなら、パスワードを扱わないのがセキュリティ上もっとも安全です。ただし自前の会員登録を受けるアプリならパスワード処理が必要になります。
ハッシュ — argon2 または bcrypt #
平文保存は絶対に NG です。 パスワードは一方向ハッシュに変換して保存します。
import argon2 from 'argon2';
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 64 * 1024,
timeCost: 3,
parallelism: 1,
});
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}argon2id は現在 OWASP の推奨です。bcrypt も依然として正当な選択肢ですが、新規プロジェクトなら argon2 を優先します。
ハッシュコストのパラメータはセキュリティと性能のトレードオフです。軽すぎるとブルートフォースに弱く、重すぎるとログインが遅くなります。上の設定は一般的なサーバーで約 ~100ms 程度を消費する推奨の出発点です。
「パスワードを自前で扱うべきでないケース」 #
次のケースでは OAuth のみで進むほうがリスクを大きく下げられます。
- ユーザー数が少なく OAuth プロバイダ1〜2個でカバーできるアプリ
- パスワード紛失 / リセットフローを自前で運営する余力がないチーム
- 2FA のような追加セキュリティを自前で実装する余力がない場合
OAuth はパスワード自体を GitHub / Google が扱い、私たちはユーザー識別だけを受け取ります。パスワード関連事故の大半を外注した ことになります。
OAuth provider — GitHub の例 #
GitHub OAuth App の登録 #
- GitHub Settings → Developer settings → OAuth Apps → New OAuth App
- Application name: アプリ名(例:
modern-react-demo) - Homepage URL:
http://localhost:3000(開発)、production は実ドメイン - Authorization callback URL:
http://localhost:3000/api/auth/callback/github - 登録後に Client ID と Client Secret を受け取ります。
環境変数 #
AUTH_SECRET=<openssl rand -base64 32 の結果>
AUTH_GITHUB_ID=<Client ID>
AUTH_GITHUB_SECRET=<Client Secret>AUTH_SECRET は JWT 署名と session encryption に使われます。production では絶対にコードに書かないでください。33章(デプロイと観測性)で扱う環境変数システムに置くのが標準です。
ログインボタン #
import { signIn } from '@/auth';
export default function LoginPage() {
return (
<main style={{ padding: '24px' }}>
<h1>ログイン</h1>
<form action={async () => {
'use server';
await signIn('github', { redirectTo: '/dashboard' });
}}>
<button type="submit">GitHub でログイン</button>
</form>
</main>
);
}signIn は27章の Server Action パターンと自然に噛み合います。フォーム内で server action として呼び出せば progressive enhancement が保証されます。
4箇所でセッションを読む #
同じセッション情報をどこでどう読むかが重要です。App Router には4つの場所があります。
1. Server Component — auth() を直接呼び出し
#
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 style={{ padding: '24px' }}>
<h1>ダッシュボード</h1>
<p>こんにちは、{session.user.name} さん。</p>
</main>
);
}Server Component 内で auth() を直接 await します。認証されていなければ redirect。middleware で大枠のルート保護を、Server Component できめ細かな権限確認を分担するパターンが一般的です。
2. Client Component — <SessionProvider> + useSession
#
Client Component でセッションを使うには、SessionProvider を root に置いて useSession フックを使います。
'use client';
import { SessionProvider } from 'next-auth/react';
import type { ReactNode } from 'react';
export function Providers({ children }: { children: ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body><Providers>{children}</Providers></body>
</html>
);
}'use client';
import { useSession } from 'next-auth/react';
export default function UserBadge() {
const { data: session, status } = useSession();
if (status === 'loading') return <span>...</span>;
if (!session?.user) return <a href="/login">ログイン</a>;
return <span>{session.user.name}</span>;
}注意: Client Component ではセッションが非同期で届くので、status も併せて確認しないとちらつきが出ます。
3. Server Action — auth() + 権限検証
#
'use server';
import { auth } from '@/auth';
import { db } from '@/lib/db';
export async function deleteTodo(id: string) {
const session = await auth();
if (!session?.user) throw new Error('ログインが必要です');
const todo = await db.todos.findById(id);
if (!todo) throw new Error('項目が見つかりません');
if (todo.ownerId !== session.user.id) throw new Error('権限がありません');
await db.todos.delete(id);
}Server Action 内部の権限検証がセキュリティの最後の防御線 です。クライアントの UI を回避して直接 RPC を呼ばれても、権限検証が抜けているとデータが露出します。Middleware はルート単位の保護で、Server Action 内の検証はデータ単位の保護です。両方のレベルが必要です。
4. Middleware — ルート保護 #
先ほど作った middleware.ts をもう一度見てみます。
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
export default auth(req => {
const isLoggedIn = !!req.auth;
const isProtected = req.nextUrl.pathname.startsWith('/dashboard');
if (isProtected && !isLoggedIn) {
return NextResponse.redirect(new URL('/login', req.nextUrl));
}
});Middleware はルートが描画される前に横取りする段階です。保護されたルートへの侵入そのものを止めてログインページへ送ります。レンダリングコストを節約する効果 もあります。
権限モデル — 小さく始める #
ほとんどのアプリは最初は次の2つで足ります。
Role-based — enum 1カラム #
export const users = pgTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name'),
role: text('role', { enum: ['user', 'admin'] }).notNull().default('user'),
});const session = await auth();
if (session?.user.role !== 'admin') redirect('/');複雑な RBAC システム(Cerbos / Casbin など)は大きな組織では価値がありますが、小さなアプリでは上のような単純な enum 1カラムのほうが明確で運用負担も少なくて済みます。
Row-level — owner_id の検証 #
自分のデータだけを扱えるパターン。先ほどの deleteTodo Server Action で見た形です。
if (todo.ownerId !== session.user.id) throw new Error('権限がありません');ほとんどの SaaS アプリはこの2パターン(role + row-level)のみで運営されます。複雑な権限体系は本当に必要になってから入れても遅くありません。
よくある罠 #
1. クライアントだけで権限確認 #
'use client';
export default function AdminButton() {
const { data: session } = useSession();
if (session?.user.role !== 'admin') return null;
return <button onClick={() => deleteEverything()}>全て削除</button>;
}UI を隠すのはよいですが、ボタンが見えないからといって動作が止まるわけではありません。deleteEverything Server Action の中でも再度検証する必要があります。クライアント側の権限確認は UX、Server Action 側の権限確認はセキュリティです。
2. パスワードをログに流す #
console.log('login attempt:', credentials); // パスワードを含む
ログを PII(個人識別情報)と一緒に見直さないと事故になります。パスワードやトークンは絶対にログに残さないでください。33章(デプロイと観測性)の Sentry セットアップでも、自動で除外されるフィールドを明示します。
3. AUTH_SECRET の漏洩 #
const AUTH_SECRET = 'my-secret-key'; // 絶対にダメAUTH_SECRET が漏れると、誰でも有効な JWT を作れます。必ず環境変数 で、そして production ではシークレット管理システム経由でだけ注入します。
4. cookie の Secure / HttpOnly / SameSite #
Auth.js v5 は cookie オプションを安全なデフォルトに設定しますが、自前で cookie を扱うコードがあるなら、次のオプションを意識的に確認してください。
HttpOnly: true— JS から cookie にアクセスできない(XSS 対策)Secure: true— HTTPS でのみ送信SameSite: 'lax'または'strict'— CSRF 対策
自分でやってみよう — 小さな保護されたページ #
次の流れを自分で作ってみてください。
- Auth.js セットアップ: 上の
auth.tsをそのまま作り、GitHub OAuth App を登録します。.env.localに環境変数。 - ログインページ + ダッシュボード:
/login(server action で GitHub ログイン)と/dashboard(Server Component でauth()でセッション確認)を作ります。 - middleware:
/dashboardを middleware で保護し、ログインしていないユーザーが直接 URL を打っても/loginに飛ぶようにします。 - Server Action 権限検証: ダッシュボードに「自分の情報を削除」Server Action を作り、
session.user.idの本人だけが自分のデータを消せるよう row-level の検証を入れます。 - Playwright で検証: 30章の
storageStateパターンを活用し、ログイン済み状態と未ログイン状態の2シナリオを自動化します。
5ステップを経ると、認証 + 権限 + middleware + Server Action + E2E が1つの流れで整理されます。
練習問題 #
- JWT vs DB session の選択練習. 次の3つのアプリにどちらのモデルがより適切かを答え、理由を書いてみてください。(a) 単一ドメインの小さなブログ + コメント、(b) 複数のマイクロサービスが同じユーザーを共有する SaaS、(c)「悪性アカウントを即時遮断」がセキュリティ要件である決済アプリ。本文の選択基準の表を参考にします。
- 権限検証4箇所のマッピング. 次の5つの要件を本文の4箇所(middleware / Server Component / Client Component / Server Action)のどこで処理するのが適切かを答えてください。(a) ログインしていないユーザーの
/dashboardアクセス遮断、(b) admin だけに見えるメニュー項目を隠す、(c) 自分の Todo だけ削除可能、(d) admin だけ他ユーザーアカウントを無効化可能、(e) ログイン済みユーザーの名前をヘッダーに表示。 - セッション情報の拡張. セッションの user オブジェクトに
role以外にorganizationIdを追加する必要があると仮定します。Auth.js v5 のどの場所(auth.ts の callbacks? adapter? Server Action?)にそのロジックを置くのが最も自然か、本文と公式ドキュメントを参考に答えてみてください。
一行まとめ: 認証は JWT(stateless)と DB session(stateful)の2モデルがあり、小さなアプリは通常 DB session で始めます。Auth.js v5 は
auth.tsの単一入口で OAuth / Credentials provider とアダプタをまとめ、App Router では4箇所 — middleware(ルート保護)、Server Component(auth()await)、Client Component(useSession)、Server Action(auth()+ row-level 検証) — でセッションを読みます。UI はクライアントで隠しつつも セキュリティの最後の防御線は Server Action 内の権限検証 です。パスワードは argon2id でハッシュ化し、AUTH_SECRETは絶対にコードに置きません。
次の章 #
次の 33章 デプロイと観測性 では、5部の最終章として、作ったアプリを production に上げ、その後に何が起きるかを見る道具を整理します。Vercel と Cloudflare Pages の選択基準、環境変数とシークレット管理、Sentry でエラー追跡、PostHog でプロダクト分析。本章の AUTH_SECRET がどこに行くかの答えも、そこで扱います。