Next.js における認可とセッション
このドキュメントは、バックエンドからセッション(トークンまたはセッションID)を受け取り、それを保存、延長、検証しなければならないフロントエンドに関するものです。プロジェクトがライブラリやプロバイダー(Auth.js、Better Auth、Clerk など)を使用している場合、セッションのメカニズムはそれらが担当しますが、セクション2、6~10のルールは引き続き有効です。コードは references/implementation.md を参照してください。
ルールの優先順位。 ユーザーの指示とプロジェクトのエージェントファイルがより重要です。バックエンドが別の方法を指示する場合(例:自ら Cookie を設定する)、バックエンドに従い、その違いを README または ADR に記録してください。
表記: MUST — 違反してはいけません; SHOULD — デフォルトでは yes、逸脱するには理由が必要です。
1. モデルの選択
| モデル | いつ使うか |
|---|---|
| BFF: Next の route handlers がバックエンドからトークンを受け取り、HttpOnly-cookie に保存する | バックエンドが応答本文でトークンを返す場合。Next のデフォルトの選択肢 |
| バックエンド自身が HttpOnly-cookie を設定する | フロントエンドと API が同一サイトにあり、バックエンドを制御下に置いている場合 |
| ライブラリまたはプロバイダー | OAuth、MFA、ユーザー管理が必要な場合 — 自作しない |
| Access はメモリに、refresh は HttpOnly-cookie に | BFF を持たない SPA |
トークンを localStorage/sessionStorage に保存 | 禁止: XSS が発生するとセッションが奪われる |
2つ目の分岐点は BFF が何をプロキシするか です:
| オプション | メリット | デメリット |
|---|---|---|
| login / refresh / logout のみをプロキシし、その他のリクエストは Cookie を持って直接 API へ | BFF がボトルネックにならない; コードが少ない | API が Cookie からトークンを読み取り、CORS を設定する必要がある; Cookie が API のドメインまで到達する必要がある |
| 全てのリクエストを BFF 経由で行う | API が Cookie と CORS を意識しなくて済む; オリジンが1つ | 各リクエストに余分なホップが発生する; BFF をスケーリングする必要がある |
選択は ADR に記録します。
2. Cookie (MUST)
- アクセス権を与えるすべてのもの(access、refresh、セッションID)に対して
HttpOnlyにします。 - 本番環境では
Secureにします。ローカルでは、ブラウザはhttp://localhostでのSecureを許可しますが、他の http ホストでは許可しません。 - デフォルトは
SameSite=Laxです。Strictは CSRF に対してより安全ですが、メールや別のサイトからのリンクを経由して来たユーザーは、最初の遷移で未認証に見えます。意図的に選択し、その理由を記録してください。 Path=/; 有効期限はトークンのexpまたはバックエンドの応答に基づき、「おおよそ」で決めないでください。- SHOULD: 名前にプレフィックス
__Host-を使用する(Secure、Path=/、Domainなしが必要) — サブドメインから Cookie を上書きできなくなります。 - Cookie の設定と削除は サーバー上でのみ(route handler、Server Action、
proxy.ts)行います。Server Components は Cookie を設定できません。 - JS から読み取り不可の Cookie が、セッションの唯一の保持者です。
is_authenticatedのようなHttpOnlyなしのフラグは、UI のヒントとしてのみ許容され、アクセス決定に使用してはいけません。
3. BFF: route handlers (MUST)
- ログインはスキーマ(
zod)で検証します; 不正な形式の本文は400を返します。 - 変更を行うハンドラーは、
Originをアプリケーションのオリジンと照合します:SameSite=Laxは同一サイトのサブドメインからのリクエストを防御しません。Server Actions はOriginとHostを自身で比較します。 - クライアントコンテキストはバックエンドに転送します:
X-Forwarded-For、User-Agent— そうしないと、バックエンドのレート制限はサーバーの IP しか認識しません。 - バックエンドのエラーは
kindとステータス(401、403、422、Retry-After付き429、503)を含む応答に変換し、バックエンドのテキストは外部に出さないでください。 - リフレッシュ: バックエンドが refresh トークンを 拒否した 場合 — Cookie を削除し
401を返します。バックエンドが 利用不可能な 場合(ネットワーク、5xx) — Cookie に触れず、503を返します: 一時的な障害でログアウトさせてはいけません。 - ログアウト: バックエンドでトークンを無効にし(エラーはログに記録しますが、表示しません)、いずれにしても Cookie を削除します。
4. セッションの確認場所 (MUST)
| 場所 | 何をするか | 何をしないか |
|---|---|---|
proxy.ts(Next 16+、以前は middleware.ts) | Cookie の有無による楽観的チェック: ログインページへのリダイレクト、およびログインページからのリダイレクト | データベースや API にはアクセスしない; 唯一の防御ではない |
Data Access Layer(server-only モジュール内の verifySession()) | データの近くで確認: サーバーコンポーネント、route handlers、Server Actions 内 | — |
| レイアウト | 何もしない: レイアウトはナビゲーション時に再描画されない | アクセスをチェックしない |
| バックエンド | 信頼できる情報源: 各リクエストでトークンを確認する | — |
- 各 route handler と Server Action は公開エントリポイントであり、自身で
verifySession()を呼び出します。 proxy.tsは refresh-cookie(またはセッションID)を確認します: access は期限切れでもセッションは有効な可能性があります。- パスの一致はセグメントベース:
/appと/app/…は一致しますが、/appleとは一致しません。 cacheComponents(Next 16.4 以降のプロジェクトではデフォルト)を使用する場合、セッション読み取りとユーザーデータのキャッシュには独自のルールがあります: DAL を書く前に、node_modules/next/dist/docs/01-app/02-guides/authentication-with-cache-components.mdを読んでください。- 権限(ロール、所有権)は検証済みデータに基づいて決定します: 署名(JWKS、
jose)またはバックエンドの応答に基づきます。検証されていない JWT ペイロードをアクセスの根拠にしてはいけません。 - Server Components は Cookie を更新できません。サーバーレンダリング時に access が期限切れの場合:
proxy.tsがセッションを更新するか(応答に Cookie を設定できます — これはドキュメントナビゲーションの場合のみ行い、prefetch では行わず、refresh トークンのローテーションを考慮してください)、またはページがクライアントサイドのロードをレンダリングし、クライアントが refresh を行います。
5. クライアント: 競合のないリフレッシュ (MUST)
- 「未認可」の判定: HTTP
401、または GraphQL 応答(200)内の少なくとも1つのエラーにextensions.code === "UNAUTHENTICATED"(errors.some、errors[0]ではない)がある場合。メッセージテキストとの比較は、バックエンドがコードを返さない場合に限り、1つの関数内で、コメントとテストを付けて行います。 - シングルフライト: 401 を受け取った並行リクエストは、1つの共通リフレッシュを待ち、その結果を使用します。Promise は
finallyでリセットされます。 - 元のリクエストは 1回だけ 再試行します。2回目の 401 はセッション喪失を意味し、2度目のリフレッシュは行いません。
- リフレッシュリクエスト自体はインターセプターを通過しません(再帰を防ぐため)。
- リフレッシュの結果は3つの結果を区別します: 発行された(
true)→ 再試行; 拒否された(false)→ セッション期限切れ; ネットワークまたは5xx(例外)→networkエラー、アカウントからのログアウトは行いません。 - ログアウト中はリフレッシュがブロックされます: そうしないと、ログアウト時に 401 で落ちたリクエストがすぐにセッションを復元してしまいます。
- 期限切れセッション: リクエストキャッシュをクリアし、現在のページの
callbackUrlを付けてログインページに移動します。 - 再試行するリクエストの本文は再試行可能(文字列、
FormData)である必要があり、使い捨てのReadableStreamであってはいけません。
6. ログイン後のリダイレクト (MUST)
callbackUrl(next、returnTo)はgetSafeRedirect(raw, fallback, appOrigin)関数で検証します:\および制御文字(ブラウザは/\evil.comや/<tab>/evil.comを//evil.comに変換します)を拒否し、new URL(raw, appOrigin)を解析し、オリジンを比較してパスのみを返します。 「/で始まり//で始まらない」というチェックは 不十分 です。- そのテストスイートは
references/implementation.md、セクション7にあります。 - ログイン後、サーバーコンポーネントは新しい Cookie を認識する必要があります: 完全なナビゲーション(
window.location.assign)またはrouter.replace+router.refresh()を使用します。
7. ログアウト (MUST)
- サーバーは Cookie を削除します(セクション3)。
- クライアントはリクエストキャッシュ(
queryClient.clear())とユーザーデータを含むすべてのクライアントストアをクリアします: 同じタブの次のユーザーが、一瞬でも他人のデータを見ることがあってはいけません。 - キャッシュされた保護画面に「戻る」ことができないように、ログインページへのナビゲーションは(
replaceまたは完全なナビゲーションで)行います。 - ログアウトの確認はプロダクトの任意; リクエスト中のボタン無効化は必須です。
- SHOULD: 他のタブがログアウトを認識する(
BroadcastChannelまたはフラグによるstorageイベント) — そうしないと、最初の 401 が発生するまでデータを表示し続けます。
8. リンク内のトークン(パスワードリセット、メール確認)
- トークンは URL から 1回だけ 状態に読み込まれ、その後、アドレスからトークンが削除されます(
router.replace(pathname)): 履歴に残らず、Refererに送信されることもありません。 - これらのページでは
Referrer-Policy: no-referrerを設定し、サードパーティのスクリプト(分析、ウィジェット)は一切使用しません。 - トークンはログに記録しません。トークンの1回限りの使用と有効期限はバックエンドが保証します; フロントエンドは「リンクの有効期限が切れました」という明確な画面と「新しいリンクをリクエスト」アクションを表示します。
9. ログインフォームと復元フォーム
- パスワード復元は、登録済みメールアドレスと未登録メールアドレスで同じ応答を返します(「アカウントが存在する場合、メールが送信されました」)、誰が登録されているかを明かしません。バックエンドが漏らす場合は、制限事項として記録します。
- 送信はリクエスト中ブロックされます; 再送信は2回目のリクエストを作成しません。
429:Retry-Afterがあれば、その待機時間を含むメッセージを表示します。- フィールド:
type="email"+autoComplete="email"; パスワードはログイン時autoComplete="current-password"、登録と変更時は"new-password"; ペーストはブロックしません。 - 「パスワードを表示」ボタンは
type="button"、aria-labelとaria-pressedを設定し、フィールドからフォーカスを奪いません。 - 「メールアドレスまたはパスワードが間違っています」というエラーは、何が間違っているのかを特定しません。
- サーバーエラーはコードに基づいてフィールドにマッピングします; 一般的なエラーはフォームの上に表示します。
10. データとログ
- ユーザーに依存する応答は、共有キャッシュ(
"use cache"、CDN)に入れてはいけません。サーバー上の QueryClient はリクエストごとに新しくします。 - ログにはトークン、パスワード、メールアドレスを含めてはいけません。最大でもユーザーIDとエラーの
kindのみです。 - セッショントークンのペイロードは最小限に: ID とロールのみで、個人データは含めません。
11. テスト
- 単体テスト:
getSafeRedirect(references からの全スイート); 「未認可」の判定(401、GraphQL コード、空の本文、JSON の代わりに HTML)。 - シングルフライト: 401 で N 個の並行リクエスト → 1 回のリフレッシュ、N 回の再試行; リフレッシュが拒否される → 再試行なし、
onSessionExpiredが1回呼ばれる; リフレッシュがネットワークエラーで落ちる →networkエラー、ログアウトなし; リフレッシュ成功後の2度目の 401 → 2度目のリフレッシュなし; ログアウト中 → リフレッシュなし。 - Route handlers: Cookie フラグ; リフレッシュ拒否 → Cookie 削除; バックエンド利用不可 → Cookie 維持、
503; 不正な本文 →400; 異なるOrigin→403。 - e2e: ログイン → 保護ページ → ログアウト → 保護ページが利用不可; access の期限切れ(テスト環境で短い TTL) → 透過的なリフレッシュ; パスワードリセットリンク → トークンがアドレスバーから消える。
12. Definition of Done
- どのトークンも JS から読み取られない;
localStorageはセッションに使用されない。 - Cookie:
HttpOnly、本番でSecure、SameSite、Path、有効期限はトークンから。 - リフレッシュ: バックエンドの拒否と利用不可が区別される。
- 結果を伴うシングルフライト、1回の再試行、ログアウト中のブロック — テストでカバーされている。
-
proxy.tsは楽観的チェックのみ; DAL は handlers、actions、およびサーバーコンポーネントで呼び出される。 - 完全なテストスイートを伴う
getSafeRedirect。 - ログアウトはクライアントのキャッシュとストアをクリアする。
- リンクからのトークンはアドレスバーから除去される。
- ログにトークンや個人データは含まれない。
- モデルとプロキシの選択が ADR に記録されている。