🍪

「JWT を localStorage に置くな」はなぜ言われるのか、Cookie 回帰までの時系列整理

に公開
2

はじめに

Webセキュリティの第一人者である徳丸浩氏が X で、JWT と Cookie セッションの関係についてこんな投稿をされていました。

これはウェブAPI呼び出しの歴史から考えると腑に落ちるのですが、CORSの機能にCookie付与があることからもわかるように、

  • (1) 昔はクロスオリジンのAPIをCookieによるセッション管理で呼び出す方法が用いられていましたが、
  • (2) サードパーティクッキー規制などでそれが難しくなり、Authorizationヘッダによるトークン(保存先はlocalStorage)に変わるものの、
  • (3) 各APIの生トークンをクライアントに保持するのはやはり危険ということでBFFにトークンを隔離すると、
  • (4) もはやJWTによるセッション管理をクライアントでする必要はないということで伝統的なCookieによるセッション管理に回帰するという流れで考えるとわかりやすいと思います。

元投稿

短い投稿ですが、ここ 20 年の Web セッション管理の歴史がきれいに 4 段階で要約されています。

「BFF を建てるのが正解なのか?」という問いに対して、BFF を立てるなら、クライアント ↔ BFF 間で JWT を使う理由はもはや無くなり、伝統的な Cookie セッションに戻る、と徳丸氏は述べています。ふりだしに戻るかのように見えるこの流れは、各段階で「なぜそうなったか」の理由がはっきりしています。

AI Coding Agentによる実装が主流となる中で、ガードレールを強いていくための前提知識としてセキュリティは外せません。そのため、現代の認証認可をどのように行っていくべきかを再整理する目的で、本記事ではこの (1)〜(4) を年表に展開し、各段階を駆動した技術的・規制的な要因を整理します。


全体像 ─ 年表でつかむ

まず、年表として全体像を提示します。

年代 できごと 代表的な Web アプリ技術 セッション管理の主流
~2005 Same-Origin Policy が定着、Cookie によるセッション管理が標準 Servlet/JSP, Struts, Rails, PHP(テンプレートエンジンでサーバ側ページ生成) Cookie セッション
2010 前後 jQuery 全盛、Ajax で部分更新が一般化 Rails 3, Spring MVC, CakePHP / jQuery + Ajax Cookie セッション
2013〜 React 公開 / SPA の台頭 React, AngularJS, Vue(SPA + REST API) Cookie セッション + 一部 JWT
2014 CORS が W3C 勧告化(XMLHttpRequest.withCredentials = true でクロスオリジン Cookie 送信が標準化) SPA + API サーバの分離が定着 Cookie セッション(クロスオリジン対応)
2015 JWT (RFC 7519) 公開 / OAuth 2.0 の普及 Auth0 などの IDaaS 台頭 Cookie セッション + 一部 JWT
2017 Safari ITP(Intelligent Tracking Prevention)開始 Next.js / Nuxt.js 登場(SSR 復権の兆し) localStorage + Authorization ヘッダへの移行開始
2017〜2020 マイクロサービスアーキテクチャが本格普及(Netflix の事例が国内にも波及、12-factor app 浸透) Docker, Kubernetes, REST API による疎結合化が一般化 ステートレス認証への要求が顕在化、JWT 採用が加速
2019〜 OAuth 2.0 for Browser-Based Apps(BCP draft)が「ブラウザにアクセストークンを置かない」設計を推奨 BFF / Token-Mediating Backend パターンの体系化 Cookie + BFF 構成への移行が思想面で進む
2020 Chrome SameSite=Lax デフォルト化 / Safari がサードパーティ Cookie 全面ブロック Next.js 9〜10、API Routes で BFF が手軽に SPA 界隈で localStorage + Bearer Token が広く普及(エンタープライズは Cookie セッション継続)
2020〜 XSS 経由のトークン窃取事例が顕在化 / BFF パターン普及 Next.js, Remix, SvelteKit, Nuxt 3(フルスタックフレームワーク) BFF にトークンを隔離
2023〜 React Server Components / Server Actions が「サーバ中心設計」を加速 Next.js App Router, Remix, SvelteKit(サーバ・クライアント境界の再設計) 既存の Cookie 回帰の流れに 実装面で合流
2024 OAuth 2.0 Security BCP(RFC 9700)公開、SPA での Implicit Flow 非推奨が標準化 Server Components 前提のアーキテクチャ Cookie セッションへの回帰が標準的な選択肢に

以下、各段階を Web アプリ技術の変遷セッション管理の変遷 を絡めながら詳しく見ていきます。


この時代の Web アプリ

サーバサイドが主役の時代です。

  • Java:Servlet + JSP、Struts、後に Spring MVC
  • Ruby:Rails(2005〜)
  • PHP:CakePHP, Symfony, Laravel
  • Python:Django

いずれもサーバ側でテンプレートエンジンを使って HTML を組み立て、丸ごとブラウザに返すスタイルです。ブラウザは受け取った HTML を表示するだけで、JavaScript は jQuery によるフォームバリデーションや軽い Ajax 更新がせいぜい。

セッションはサーバ側に持つのが当たり前で、ブラウザには JSESSIONID のようなセッション ID を HttpOnly Cookie で渡しておけば良かった。そもそも認証情報をクライアントで「持つ」という発想がなかったのです。

そもそも当時の Web アプリは 大半が同一オリジン構成でした。Servlet/JSP や Rails が app.example.com で HTML を返し、必要なら同じドメインの API を XMLHttpRequest で叩く ─ という構図です。Cookie セッションは同一オリジンであれば何の苦労もなく機能するので、それが当たり前でした。

クロスオリジンの API 呼び出しはむしろ例外的な要件で、当時はブラウザによって挙動がバラバラでした。CORS が W3C 勧告として標準化される 2014 年以前は、XMLHttpRequest.withCredentials などブラウザベンダ独自の実装で条件付きにクロスオリジン Cookie を伴う API 呼び出しが行われていたのは確かですが、互換性問題から以下のような回避策のほうがむしろ一般的でした。

  • JSONP<script> タグの仕組みでクロスオリジン制約を回避(GET 限定、CSRF リスクあり)
  • リバースプロキシapi.example.comapp.example.com/api にプロキシして同一オリジン化
  • iframe + postMessage:別オリジンを iframe で読み込み、メッセージ経由でやりとり
  • そもそも同一オリジンに寄せる:API もアプリも同じドメイン配下に置く

いずれにせよ、サーバ側は伝統的なセッション ID ベースの管理で済み、フロントエンドはトークンの存在を意識する必要すらなかった。これが (1) の世界です。

何が良かったか

  • Cookie の各属性で 対応する脅威ごとに多層防御を組める(ただし SameSite 属性自体は後年導入)
    • HttpOnly:JavaScript からの Cookie 窃取を防ぐ(XSS そのものは防げない)
    • Secure:HTTPS 限定送信で 平文盗聴を防ぐ
    • SameSite=Lax/Strict:クロスサイトからの自動送信を抑止し CSRF を軽減

SameSite は CSRF の強力な軽減策だが、万能ではありません。
Lax はトップレベル GET ナビゲーションでは依然として Cookie を送信するため、以下のような攻撃面は残ります。

  • 状態変更 GET:副作用のある GET エンドポイント(GET /delete?id=... のような設計)は SameSite では守れない
  • login CSRF:被害者を攻撃者のWebサイトに強制的にログインさせる攻撃
  • OAuth コールバック周辺:トップレベル遷移として実行されるため SameSite では止まらない

したがって SameSite に依存しきらず、状態変更は必ず POST / PUT / DELETE にする、CSRF トークンを併用する、Origin / Referer ヘッダを検証する など、複数の対策を組み合わせるのが前提です。

  • セッションの即時無効化が容易(サーバ側のストアを消すだけ)
  • 20 年以上磨かれてきたベストプラクティスが揃っている

なお、XSS 自体は Cookie 属性では防げません。XSS 対策は CSP / 入力サニタイズ / 自動エスケープ等で別途行う必要があります。


(2) 2017〜2020年:SPA 時代、複合要因で localStorage + JWT が広まる

この時代の Web アプリ ─ SPA の登場

2013 年に React が公開され、Vue(2014)、Angular の刷新(2016)と続き、SPA(Single Page Application) が一気に主流になります。

  • フロントエンド:React / Vue / Angular(静的ホスティング、CDN 配信)
  • バックエンド:REST API サーバ(Rails API モード、Express、Spring Boot など)
  • 認証:別ドメインの認証サーバ(Auth0 などの IDaaS も普及)

ここで起きた構造変化が 「フロントエンドとバックエンドが別オリジンになる」 ことでした。app.example.com(CDN 上の SPA)から api.example.com(API サーバ)を呼ぶ ─ という構図が一般化します。

サーバ側でテンプレートを組み立てる時代は終わり、ブラウザの JavaScript がページの組み立てから API 呼び出しまでを担うようになりました。認証情報を JavaScript から扱う必要が初めて出てきたのです。

トラッキング目的のサードパーティ Cookie への規制が、ブラウザ各社で段階的に強化されました。

  • 2017年:Safari が ITP(Intelligent Tracking Prevention)を導入
  • 2020年2月:Chrome が SameSite=Lax をデフォルト化
  • 2020年3月:Safari がサードパーティ Cookie を全面ブロック
  • 2020年〜:Chrome もサードパーティ Cookie の段階的廃止を表明

補足:origin / site / third-party の違い

ここで誤解されやすいので整理しておきます。ブラウザの Cookie 制約は オリジン (origin) ではなく サイト (eTLD+1) ベースで効きます。

パターン cross-origin か cross-site か Cookie 制約の影響
app.example.comapi.example.com YES(origin が違う) NO(同一 site) ほぼ受けない(first-party 扱い)
app.example.comauth0.com(外部 IdP) YES YES(別 site) 強く受ける(3rd-party cookie 制約の主戦場)

つまり「自社の app と api を同じサイト(eTLD+1)配下に置く」運用にできれば、Cookie セッションは今でも普通に機能します。サードパーティ Cookie 規制の主戦場は 完全に別サイトの外部 IdPauth0.com, login.microsoftonline.com など)や広告トラッカーであって、自社サブドメイン構成ではありません。

しかし現実には、別サイトの IdP との SSO 連携や、複数 SaaS を跨ぐ認証など、3rd-party cookie に依存せざるを得ない構成が増えていました。これが Cookie 規制の影響を受ける場面です。

もう一つの背景 ─ マイクロサービスとステートレスアーキテクチャ(「プル要因」)

サードパーティ Cookie 規制が「プッシュ要因」だとすれば、もう一つの大きな「プル要因」がありました。それが マイクロサービスアーキテクチャREST API のステートレス原則です。

2014 年頃に Martin Fowler が "Microservices" を提唱し、Netflix の事例が広く紹介され、Docker(2013)と Kubernetes(2014)が後押しする形で、2017〜2020 年頃にかけて国内エンタープライズでも本格的に普及しました。同時期に 12-factor app の方法論も浸透し、「プロセスはステートレスであるべき」という設計原則が広く共有されるようになります。

マイクロサービス時代に従来型のセッション管理が抱えた問題は明白でした。

  • サーバ側セッションストアの共有問題:複数のサービスインスタンスが同じセッションを参照するために、Redis などの共有ストアを介在させる必要がある
  • サービス間認証の煩雑さ:サービス A がサービス B を呼ぶ際、毎回セッションサーバに問い合わせるオーバーヘッドが発生する
  • スケールアウトの障壁:セッションアフィニティ(スティッキーセッション)が必要だと、ロードバランシングの自由度が下がる

ここで JWT が 「署名検証だけで認証完結、共有ストア不要」 という性質で脚光を浴びます。

[クライアント] ──(JWT)──> [サービスA] ──(JWT)──> [サービスB]
                             ↑                       ↑
                  各サービスは署名検証だけで認証完結
                  (セッションストアへの問い合わせ不要)

注意: この図は当時「ステートレスで美しい設計」として推奨されていたパターンを示しています。クライアントが直接 JWT を保持してマイクロサービス群を呼び出す構成は、後の (3)(4) で 「クライアントに高価値トークンを持たせる構造的リスク」 として問題視され、BFF 経由のパターンに置き換わっていきます。

サードパーティ Cookie 規制で Cookie が使えなくなった」 と 「マイクロサービスでステートレスにしたい」 という 2 つの力が同じ方向(JWT 採用)に働いたわけです。だからこそ JWT は急速に標準的な選択肢として広まりました。

JWT + localStorage 構成が広まった複合要因

ここまでの整理をまとめると、「3rd-party cookie 規制で Cookie が使えなくなったから JWT に行った」という単純な因果ではなく、実際には 複数の要因が同時に作用 していたことが分かります。

  • SPA の台頭:ブラウザの JavaScript が認証情報を扱わざるを得ない構造になった
  • OAuth 2.0 / OIDC の普及:認可サーバが発行するトークンの形式として JWT が事実上の標準に
  • 外部 IdP との連携:別サイトの IdP との橋渡しで 3rd-party cookie に頼れない場面
  • モバイル / ネイティブアプリ:そもそも Cookie が扱いにくい環境でのトークン認証
  • API Economy / multi-domain SaaS:複数の独立サービスを横断する認証ニーズ
  • マイクロサービスのステートレス要求:前節で詳述したプル要因

これらが重なる中で、特に SPA 界隈(React / Vue を中心としたフロントエンド主導の開発文化) では、以下の構成が広く採用されました。

  • 認証成功時、サーバが JWT を発行
  • クライアントは JWT を localStorage(または sessionStorage)に保存
  • API 呼び出し時に Authorization: Bearer <JWT> ヘッダで送る

JWT は署名検証だけで認証できる(ステートレス)ので、認証サーバとリソースサーバを分離しやすい ─ という触れ込みも追い風になりました。

ただし、これは「業界全体のデフォルト」ではなかった

ここも誤解されやすいので強調しておきます。localStorage + Bearer Token が広まったのは 主に SPA / フロントエンド主導の文化圏であり、業界全体が一斉に JWT 化したわけではありません。

  • 金融 / エンタープライズ / 公的システム:従来通り Cookie セッションを継続
  • Java / Spring 系JSESSIONID 文化が普通に残存(Spring Security の formLogin など)
  • Rails / Django / Laravel:フレームワーク標準のセッション機構を継続利用
  • PHP 系の業務システムPHPSESSID で長く運用

さらに、SPA 界隈の中でも後期にはセキュリティ意識の高まりに伴い、Auth0 などの IDaaS 自体が以下のような 「localStorage を避ける方向」 に移行していきました。

  • in-memory token:メモリ上のみに保持し、リロードのたびにリフレッシュ
  • rotating refresh token:リフレッシュトークンの一回使い切り化
  • iframe silent auth の廃止:3rd-party cookie 規制と整合しなくなり代替手段へ

つまり、この時代の「JWT + localStorage」は 特定の文化圏で広まった流行であり、しかもその文化圏の中でも徐々に問題視されていったというのが正確な姿です。SPA だから必然的に JWT になったわけではないことに留意してください。同一サイト運用 / リバースプロキシ / 後述する BFF などを採用すれば、SPA でも Cookie セッションは十分機能します。本記事の (4) で語るのはまさにその「Cookie セッションへの回帰」です。

しかし、ここに大きな落とし穴があった

localStorage は JavaScript から読める。

つまり XSS が一度でも刺されば、トークンが丸ごと攻撃者に渡ります。HttpOnly Cookie のような防御は localStorage には存在しません。もちろん CSP(Content Security Policy)や Trusted Types、X-Content-Type-Options、フレームワーク側の自動エスケープ 等で XSS の発生確率自体を下げることはできますが、ブラウザの JavaScript から可読な領域に bearer token を置くという構造的リスクは残り続けます。XSS をゼロにできない以上、「漏れたら終わり」のトークンを可読領域に置くこと自体が設計上の妥協です。

加えて JWT 固有の問題:

  • ログアウト時の即時無効化が困難(ステートレスなので、有効期限内のトークンは原理的に有効)
  • JWT ライブラリや署名検証の実装不備(初期には alg: none 受容や HS/RS 鍵取り違えなどの問題が報告された。現在は主要ライブラリ側で対策済みだが、自前実装や古いライブラリには注意が必要)

そして「ステートレスのジレンマ」が露呈する

上記のうち 「即時無効化ができない」 が、現場では特に深刻でした。

考えてみれば当然です。

  • ユーザーがログアウトボタンを押した → サーバはトークンを「無効」にしたい
  • だが JWT はサーバが署名しただけの文字列で、サーバはそれを「覚えていない」
  • 有効期限が切れるまでは、漏れたトークンは攻撃者がそのまま使える

「アクセストークンの有効期限を短くしてリフレッシュトークンで更新する」というベストプラクティスもこのためですが、それでも漏洩から失効までのウィンドウは残ります。

そこで現実解として採用されたのが以下のような 対症療法 でした。

  • ブラックリスト方式:無効化したトークンの jti(JWT ID)を Redis などに保存し、検証時に毎回問い合わせる
  • ホワイトリスト方式:発行したトークンをすべてサーバ側に記録し、検証時に存在確認する
  • トークンバージョニング:ユーザレコードに tokenVersion を持たせ、ログアウト時にインクリメント、検証時に照合

ここで JWT 採用の前提が崩れます。

(2) 冒頭で書いたとおり、JWT が採用された最大の理由の一つは 「マイクロサービス時代のステートレス認証」 でした。共有ストアへの問い合わせを無くしたいから、署名検証だけで完結する JWT を選んだはずです。

それなのに即時無効化のために 共有ストアへの問い合わせ(ブラックリスト / ホワイトリスト)が必要になる ─ これは、ステートレスを捨てて従来のセッション管理に近づいていることに他なりません。

従来のセッション管理:
  検証時 → セッションストア(Redis 等)に問い合わせ → OK/NG

JWT + ブラックリスト:
  検証時 → 署名検証 → さらに ブラックリスト(Redis 等)に問い合わせ → OK/NG

                  結局これがあるなら最初から
                  セッション管理で良くないか?

これが 「ステートレスのジレンマ」 です。

  • 純粋なステートレス JWT → 即時無効化できず、セキュリティ要件を満たせない
  • ステートを持たせた JWT → JWT を採用したそもそもの理由(ステートレス)が失われる

このジレンマに直面した結果、「それなら最初から伝統的なセッション管理で良いのでは?」という再評価が起きます。そしてこの問いが、(3) の BFF パターン → (4) の Cookie セッションへの回帰 という流れを生んでいくことになります。


(3) 2020年〜:Next.js などのフルスタックフレームワーク、BFF にトークンを隔離する

この時代の Web アプリ ─ フルスタックフレームワークの台頭

Next.js, Nuxt.js, SvelteKit, Remix といったフルスタックフレームワークが普及します。これらは以下の特徴を持ちます。

  • フロントエンド(React など)とサーバ(Node.js)が同じプロジェクト・同じデプロイ単位になる
  • ページごとに SSR / SSG / CSR を選べる
  • API Routes / Server Routes同じドメイン上に API エンドポイントを生やせる

つまり、これらのフレームワーク自体が 「BFF を構築するための器」 として機能するようになりました。「クライアントと別ドメインの API サーバを呼ぶ」というそもそもの構造を、フレームワーク側で吸収してくれるわけです。

BFF パターンの普及

そもそも BFF パターンが提唱された理由の一つが、(2) で進んだマイクロサービス化の副作用でした。マイクロサービスに分割した結果、

  • クライアントが多数のサービスを直接呼び分けることになる
  • 画面表示のために 5〜10 個の API を順次/並列に叩く必要がある
  • クライアント側で集約・整形ロジックが肥大化する

という問題が顕在化し、クライアント専用の集約レイヤーとして BFF が登場します。

そこに「クライアントに生トークンを置くのは危険」というセキュリティ要請が重なり、BFF(Backend For Frontend) にトークンを隔離する構成が一気に主流化しました。

[ブラウザ] ──(Cookie)──> [BFF] ──(JWT/Bearer)──> [API群]
  • BFF はサーバサイドで JWT を保持する
  • ブラウザと BFF の間は HttpOnly Cookie でセッション ID をやりとりする
  • BFF が背後の API を呼ぶときだけ JWT / Bearer トークンを使う

これにより、ブラウザ側に生トークンが存在しなくなり、XSS による窃取(token exfiltration)リスクが大幅に下がります

ただし、XSS そのものを防ぐわけではない

ここは正確に押さえておく必要があります。HttpOnly Cookie が防ぐのはあくまで 「トークンが JavaScript から読まれる」 こと(窃取耐性)であって、XSS 自体が無効化されるわけではありません。

XSS が刺さった攻撃者は、HttpOnly Cookie の中身を読めなくとも、ブラウザ上でこういうコードを実行できます。

// 攻撃者の任意スクリプトが XSS で実行された場合
fetch("/api/delete-account", { method: "POST" })
// → ブラウザは BFF への Cookie を自動付与する
// → BFF は認証済みリクエストとして処理してしまう

これは session riding(攻撃者がユーザのセッションに「便乗」する)と呼ばれる攻撃です。Cookie の中身は読めなくても、Cookie が「使える」状態には変わりないためです。

HttpOnly Cookie は「トークン窃取耐性」を高めるが、XSS 自体を防ぐわけではない。

したがって BFF パターンを採用しても、XSS 対策(CSP、入力サニタイズ、テンプレートエンジンの自動エスケープ、信頼できない dangerouslySetInnerHTML の排除など)と CSRF 対策(SameSite=Lax/Strict、CSRF トークン、Origin/Referer 検証)は依然として必要です。

それでも BFF + HttpOnly Cookie が価値を持つのは、「漏れたら終わり」のクリティカルな資産(API アクセストークン、リフレッシュトークン、他サービスへの認可情報)をブラウザ外に隔離できるためです。

ただし回避できるのはあくまで 「トークンがブラウザ外に持ち出されて、攻撃者のサーバから永続的にリプレイされる」 という 長期被害シナリオです。XSS が刺さっている間に session riding で実行される 短期被害(送金・退会・データ書き換え等)は引き続き発生しうるので、XSS 対策・CSRF トークン併用・重要操作の再認証など、他の対策の重要性は変わりません。BFF + HttpOnly Cookie は被害を「セッション継続中」に局所化する仕組みであり、被害そのものをゼロにするものではない、という理解が必要です。

この段階でも JWT は「BFF 〜 API 間」に残っていた

「クライアント ↔ BFF はセッション Cookie、BFF ↔ API は JWT」という二段構えになり、まだ JWT は使われていました。


(4) 現在:Cookie 回帰が「サーバ中心設計」の流れと合流する

ここは因果を強調しすぎないように整理する必要があります。Cookie セッションへの回帰の流れは、Server Components の登場よりずっと前から、セキュリティ界隈ですでに始まっていました

  • OAuth 2.0 Security Best Current Practice (RFC 9700, 2024):SPA / public client での Implicit Flow を非推奨化、Authorization Code + PKCE を推奨、リフレッシュトークン取り扱いの厳格化
  • OAuth 2.0 for Browser-Based Applications (BCP draft):ブラウザ実行コードでアクセストークンを扱うリスクを明示し、BFF 経由のパターンを推奨
  • Backend-for-Frontend パターン:Sam Newman らによる体系化(2015〜)
  • Confidential SPA / Token-Mediating Backend:トークンをサーバ側に閉じ込め、ブラウザは Cookie でしかサーバと話さない構成

つまり Cookie 回帰の駆動力は 「ブラウザに高価値トークンを露出させない」という規範の確立 にあり、RSC はその後に登場した相乗効果に過ぎません。

Server Components が後押ししたこと

ただし、Next.js App Router(2023〜)の React Server Components(RSC)と Server Actions は、この「サーバ中心設計への回帰」を強力に加速しました。

  • データ取得や認証チェックは Server Components 側で実行
  • フォーム送信は Server Actions で(サーバ側関数を直接呼ぶ感覚)
  • クライアント側 JavaScript は本当に必要な箇所だけ

この構成では、そもそもブラウザ側で API トークンを扱う場面が激減します。データ取得もミューテーションも、サーバ側でセッションを参照しながら実行されるためです。

(2) の SPA 時代に「ブラウザの JavaScript が認証情報を扱わざるを得ない」状況に追い込まれた構造が、すでに進んでいた BFF パターン化と RSC によってサーバ側に押し戻されたと言えます。サーバ側でレンダリングするということは、サーバ側でセッションを参照できるということです。

要するに、

  • 思想面:OAuth BCP / SPA Security BCP / BFF パターンが「ブラウザにトークンを置くな」と方向付けた
  • 実装面:Next.js / RSC / Server Actions が「サーバ中心の開発体験」を提供して受け皿になった

この二つが揃ったことで、Cookie セッションへの回帰が現実的かつ快適な選択になった ─ というのが (4) の実像です。

徳丸氏の指摘 ─ ただし「JWT が消えた」のではなく境界が動いた

徳丸氏の投稿の核心はここです。

もはやJWTによるセッション管理をクライアントでする必要はないということで伝統的なCookieによるセッション管理に回帰する

ここで重要なのは 「クライアントで」 という限定詞です。「JWT が不要になった」のではなく、「クライアントが JWT を持つ必要が無くなった」と読むのが正確です。

(2) で JWT 採用を駆動した複合要因 ─ 外部 IdP 連携、SaaS 横断認証、マイクロサービスのステートレス要求 ─ は今も有効です。これらの要件は (3) の BFF パターンによって 「JWT を扱う場所」が動いた だけです。

(2) で挙げた課題 (2) での解決(ブラウザに JWT) (4) での解決(BFF が肩代わり)
外部 IdP との連携 ブラウザが IdP の JWT を localStorage に保持 BFF が OAuth client として IdP と通信、JWT はサーバ保持
外部 SaaS / API 呼び出し ブラウザが SaaS API トークンを保持 BFF が Bearer Token を保持して呼び出し
マイクロサービス間認証 ブラウザが各サービスの JWT を持つ BFF が JWT / mTLS でサービス群を呼ぶ
Cookie のクロスサイト問題 そもそも Cookie を使わない Browser ↔ BFF を same-site で組み、Cookie で十分

つまり (3) 以降の構成では、

  • Browser ↔ BFF:自分のコントロール下にある same-site 関係 → Cookie セッションで十分
    • 統合型(Next.js / Nuxt / SvelteKit)なら same-origin
    • サブドメイン分離型(app.example.combff.example.com)なら same-site, cross-origin(Cookie は first-party 扱い)
    • 完全別サイトに置く構成は cross-site となり 3rd-party cookie 規制の影響を受けるため、現実には CNAME を切って同一サイトに寄せる
  • BFF ↔ 外部:(2) で必要とされた JWT / OAuth は ここに引き継がれて生き続ける

という二段構成になります。「クライアント ↔ BFF 間で JWT を介在させる必然性が無くなった」というのは、JWT が一切不要になったのではなく、JWT を扱う責任がブラウザからサーバ(BFF)に移った という意味です。

JWT のステートレス性というメリット(即時無効化の困難というデメリットと表裏)も、それを必要とするのは サーバ間 であって、ブラウザに持たせる必要は無かった ─ これが (4) の整理です。

「回帰」と言っても、2005 年に戻ったわけではない

ここは誤解されやすいので慎重に整理します。

(1) の時点でも構成は [ブラウザ] ──(Cookie)──> [サーバ] でした。(4) の構成も [ブラウザ] ──(Cookie)──> [BFF] ──(任意)──> [API群] で、ブラウザから見た認証の見え方 は (1) と同じです。だから「回帰」と呼んでいるわけですが、これはあくまで ブラウザ境界の話 に限定した話です。

ブラウザ境界より内側、つまりサーバ側のアーキテクチャは 2005 年とは全く別物になっています。

観点 2005 年頃 現在 (2024〜)
セッションストア Tomcat の HttpSession(プロセス内メモリ)/ファイル Redis / Memcached / DynamoDB / Cloudflare KV などの分散キャッシュ
トークン形式 不透明なセッション ID(独自実装) opaque token / JWE(暗号化 JWT)/用途で使い分け
実行環境 単一のアプリケーションサーバ エッジランタイム / サーバーレス / 複数リージョン
認証基盤 自前実装 OAuth 2.0 / OIDC / IDaaS(Auth0, Cognito, Entra ID)
サーバ間認証 同一アプリ内なので問題化せず JWT / mTLS / Service Mesh で明示的に設計
BFF 概念として存在しない Next.js, Nuxt, SvelteKit などフレームワーク内蔵

つまり正確には、

「ブラウザ境界では Cookie が再評価された」
(サーバ側は分散・エッジ・OIDC・BFF 等で大きく進化している)

というのが現在の姿です。「Cookie セッションに戻る」は、ブラウザに対する露出面のプロトコル選択として戻った、と理解してください。

レイヤーごとの整理

レイヤー 認証手段 理由
Browser ↔ BFF HttpOnly Cookie(要件次第で分散セッションストア / 暗号化ステートレス Cookie のいずれか) XSS による窃取耐性、SameSite + CSRF トークン等の多層 CSRF 防御、即時無効化が必要なら DB セッション、不要なら暗号化 Cookie
BFF ↔ 外部 API JWT / OAuth2 アクセストークン サービス間のステートレス認証、横展開が容易
マイクロサービス間 JWT / mTLS / Service Mesh ゼロトラスト、サービスメッシュ層で透過的に検証

「JWT は不要になった」のではなく、「クライアント露出面では Cookie、サーバ間ではトークン」というレイヤーごとの役割分担が確立した、というのが (4) の実像です。


まとめ ─ なぜ「回帰」と呼ぶのか

セッション管理の観点

段階 クライアントが持つもの 直面した課題 解決
(1) Cookie 時代 セッション ID(HttpOnly Cookie) SPA 化・外部 IdP 連携・3rd-party cookie 規制などで扱いが難化 → (2)
(2) localStorage 時代 生 JWT / Bearer Token XSS で窃取される / 無効化困難 / ステートレスのジレンマ → (3)
(3) BFF 時代 セッション ID(Cookie) + 背後で JWT BFF 側で JWT を扱う複雑さ、クライアント露出面では JWT 不要 → (4)
(4) 回帰 セッション ID(Cookie)のみ (ブラウザ境界に対する現状ベスト)

Web アプリ技術の観点

段階 代表技術 レンダリング 認証情報の扱い場所
(1) Servlet/JSP, Rails, PHP サーバ側で HTML 生成 サーバ
(2) React / Vue + REST API クライアント側で組み立て クライアント(localStorage)
(3) Next.js, Nuxt.js(API Routes) SSR + CSR の混在 BFF(サーバ)
(4) Next.js App Router, Server Components サーバ側で組み立て直し サーバ

2 つの表を並べると、「認証情報の扱い場所」が「サーバ→クライアント→BFF→サーバ」と動いたタイミングと、セッション管理方式が変わったタイミングがきれいに重なっていることが分かります。つまりセッション管理の変遷は、単独で起きたわけではなく、Web アプリのレンダリング方式の変遷と並走していたわけです。

各段階での移行は、いずれも前段階の限界を克服するために起きた合理的な進化でした。そして 20 年経って、ブラウザ境界に対する答えは「Cookie セッションで良い」という結論に再評価されました。

ただし誤解しないでほしいのは、これは 「2005 年に戻った」のではない ということです。戻ったのはあくまで ブラウザに見せるプロトコル選択 であって、その背後では、

  • セッションストアは Redis / Memcached / 分散キャッシュ
  • 実行環境は エッジランタイム / サーバーレス / 複数リージョン
  • 認証基盤は OAuth 2.0 / OIDC / IDaaS
  • サーバ間認証は JWT / mTLS / Service Mesh
  • フロント層は BFF / Next.js / SvelteKit など現代のフルスタックフレームワーク

といった現代のインフラがフル稼働しています。「ブラウザ境界では Cookie が再評価された」、これが正確な言い方です。

マイクロサービス時代に求められたステートレス認証の要求は今も有効ですが、それは サーバ間の話(BFF ↔ API、マイクロサービス間)であって、クライアントにまで JWT を持ち出す必然性はなかった ─ というのが (4) のレイヤー表で示した役割分担の意味するところです。「JWT が間違っていた」のではなく、適用すべきレイヤーが整理された と理解するのが正しいでしょう。


付録:NextAuth × Next.js Middleware による実装例

理屈の話だけだと地に足がつかないので、最後に 「クライアント ↔ サーバ間は Cookie セッション、サーバ間は JWT / OAuth2」 という原則を、実際に Next.js + NextAuth (Auth.js v5) でどう実装するかを示します。

アーキテクチャ全体像

                ┌──────────────────────────────────────────────────┐
                │                  Next.js (BFF)                   │
                │                                                  │
[Browser] ─────►│  Middleware (Edge)                               │
   ▲   HttpOnly │   └─ session 検証 / 認証必須ルートの保護          │
   │   Cookie   │                                                  │
   │            │  Server Components / Server Actions / Route      │
   │            │  Handlers                                        │
   │            │   └─ session から access_token を取り出して       │
   │            │       Authorization: Bearer で外部 API を呼ぶ     │
   │            └────────┬─────────────────────────────────────────┘
   │                     │
   │                     │ Authorization: Bearer <access_token>
   │                     │ (mTLS / Service Mesh も任意で併用)
   │                     ▼
   │           ┌────────────────────────┐
   │           │  外部 API / マイクロ    │
   │           │  サービス群             │
   │           │  (JWT / OAuth2 / mTLS) │
   │           └────────────────────────┘

   └─ ブラウザに渡るのは中身を参照できない HttpOnly Cookie のみ
      (実体は JWE 暗号化 JWT。サーバ側 AUTH_SECRET でのみ復号可能)
      (access_token / refresh_token はブラウザに一切露出しない)

このアーキテクチャの肝は 「ブラウザに JWT を渡さない」 ことです。NextAuth のデフォルトを使うとこれが自然に実現できます。

NextAuth の session 戦略は "jwt" だが、ブラウザに JWT は渡らない

ここが誤解されやすいのですが、NextAuth (Auth.js) の session: { strategy: "jwt" } という設定でブラウザに JWT が「読める形で」漏れることはありません

  • NextAuth は session 情報を JWE (暗号化 JWT) にエンコード
  • それを __Secure-next-auth.session-token という HttpOnly Cookie に格納
  • ブラウザの JavaScript からは中身を読めない(document.cookie にも出てこない)
  • 復号鍵はサーバ側の AUTH_SECRET のみが持つ

ブラウザから見たときの 露出面のプロトコル としては、伝統的なセッション Cookie と同じく「不透明な HttpOnly Cookie が一つあるだけ」に見えます。この意味では本記事の (4) で言う「Cookie セッションへの回帰」の具体形と言えます。

ここは正確に押さえる必要があります。NextAuth の jwt strategy は 依然としてステートレス であり、伝統的な DB ベースのセッション管理とは性質が違います。

観点 DB セッション(database strategy) NextAuth jwt strategy
サーバ側ストアへの保存 YES(Redis / RDB 等) NO(Cookie 自体がセッション本体)
即時失効(特定ユーザのログアウト) 容易(ストアのレコードを消すだけ) やや難(Cookie はクライアントが持ち続けられる)
「全デバイスからログアウト」 容易(該当ユーザのセッションを一括削除) (ユーザレコードに tokenVersion 等を持つ必要がある)
管理者による強制ログアウト 容易 (同上)
完全ステートレス NO YES
水平スケール時の共有ストア 必要 不要

つまり (2) で述べた 「JWT は即時無効化が難しい」というステートレスのジレンマは、NextAuth の jwt strategy でも完全には解消されていません

要件に応じて strategy を選ぶ

NextAuth は database strategy も選べます。Adapter(Prisma / Drizzle / DynamoDB など)と組み合わせれば、サーバ側ストアにセッションを保存する伝統的な形になります。

要件 推奨される戦略
即時失効・管理者強制ログアウトが必須(金融・医療・管理画面など) database strategy(伝統的な DB セッション)
即時失効はある程度妥協できる(一般的な Web サービス) jwt strategy + 短めの maxAge + リフレッシュ
トークン取り消しを厳密にやりたいが jwt を使いたい ユーザレコードに tokenVersion を持って jwt callback で照合(= 結局ストア参照)

最後の選択肢は (2) で議論した「ステートレスのジレンマ」そのものです。NextAuth を使っても、要件次第ではこのジレンマと再び向き合うことになります。

本記事の主旨(クライアント露出面では Cookie セッション)と NextAuth の jwt strategy が整合するのは、あくまで 「ブラウザ境界の話」 の範囲です。サーバ側の即時失効要件まで含めて伝統的なセッション管理に「完全に回帰」したいなら、database strategy のほうが素直です。

🚨 最重要のアンチパターン:session() callback に access token を載せない

実装に入る前に、Auth.js / NextAuth で最も頻発する事故を共有しておきます。

ネット上の多くのサンプルコードで以下のような session() callback を見かけます。

// 🛑 アンチパターン(やってはいけない)
callbacks: {
  async session({ session, token }) {
    session.accessToken = token.accessToken as string
    return session
  },
}

これは 記事の主旨と真逆の結果 を生みます。なぜか。

session() callback の返り値は 以下すべての経路でクライアントに露出します

  • useSession()"use client" 配下)
  • /api/auth/session エンドポイント(ブラウザから直接アクセス可能、ログイン中なら誰でも見える
  • getSession() のクライアント呼び出し

つまり、

"use client"
import { useSession } from "next-auth/react"

export function Bad() {
  const { data } = useSession()
  console.log(data?.accessToken) // ← ブラウザに access token が露出!
  return null
}

document.cookie には載らないので一見安全に見えますが、DevTools の Network タブで /api/auth/session を覗けば JSON でそのまま見えます。XSS が刺されば fetch("/api/auth/session") で奪取可能です。

これでは「ブラウザに JWT を渡さない」という記事の主張が完全に崩れます。session() callback には UI に必要な最小限の情報(user.id, name, role など)以外を載せてはいけません

前提整理:JWT payload と session object は別物

Auth.js / NextAuth でよく混乱するポイントを先に整理しておきます。jwt() callback と session() callback で扱うオブジェクトは 同名でも別物 です。

┌──────────────────────────────────────────────────────────────────┐
│ サインイン直後の流れ                                              │
│                                                                    │
│  Provider から profile + account 取得                              │
│            │                                                       │
│            ▼                                                       │
│  ┌─────────────────────┐                                          │
│  │ jwt() callback       │   ← JWT payload を作る                  │
│  │   引数: token, account│      (= 暗号化 Cookie の中身)          │
│  │   戻り値: token       │      サーバ側だけが復号可能              │
│  └─────────┬───────────┘                                          │
│            │ token は JWE 暗号化されて HttpOnly Cookie へ          │
│            ▼                                                       │
│  ┌─────────────────────┐                                          │
│  │ session() callback   │   ← session object を作る                │
│  │   引数: session, token│      (= クライアントに見せる shape)    │
│  │   戻り値: session     │      `useSession()` / `/api/auth/session`│
│  └─────────────────────┘      で **誰でも見られる**                │
└──────────────────────────────────────────────────────────────────┘

つまり、

JWT payload (token) session object (session)
作るところ jwt() callback session() callback
保存先 暗号化 Cookie の中身(サーバのみ復号可) /api/auth/session レスポンス、useSession()data
クライアントから見えるか 見えない 見える
入れて良い情報 access token、refresh token、外部 ID など UI 表示用の name, email, role など

session() callback で session.foo = token.foo と書くのは、JWT payload からクライアント側 session object への「公開コピー」操作 です。ここに access token を載せれば、それは「クライアントに公開する」と宣言したのと同じ意味になります。

正しい設計:access token は JWT 内に留め、サーバ専用ヘルパで取り出す

// auth.ts
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [Google],
  session: { strategy: "jwt" },
  callbacks: {
    async jwt({ token, account }) {
      // ✅ accessToken / refreshToken は JWT(暗号化 Cookie 内)にのみ保持
      if (account) {
        token.accessToken = account.access_token
        token.refreshToken = account.refresh_token
        token.expiresAt = account.expires_at
      }
      if (Date.now() < (token.expiresAt as number) * 1000) {
        return token
      }
      return await refreshAccessToken(token)
    },
    async session({ session, token }) {
      // ✅ UI に必要な最小限の識別情報のみ。accessToken は絶対に載せない
      if (token.sub) session.user.id = token.sub
      return session
    },
  },
  cookies: {
    sessionToken: {
      name: "__Secure-next-auth.session-token",
      options: {
        httpOnly: true,
        sameSite: "lax",
        secure: true,
        path: "/",
      },
    },
  },
})

access token を サーバサイドからのみ 取り出せるヘルパを別ファイルに用意します。

// lib/server/access-token.ts
import "server-only" // ← クライアントから import したらビルドエラーになる
import { getToken } from "next-auth/jwt"
import { headers } from "next/headers"

export async function getAccessToken(): Promise<string | undefined> {
  const reqHeaders = await headers()
  const token = await getToken({
    req: { headers: reqHeaders } as any,
    secret: process.env.AUTH_SECRET!,
    secureCookie: process.env.NODE_ENV === "production",
  })
  return token?.accessToken as string | undefined
}

ポイント:

  • accessToken / refreshTokenJWT(暗号化 Cookie の中身)にのみ存在
  • session() callback には UI 用の最小情報のみ転記。/api/auth/sessionuseSession() 経由で漏れる経路を遮断
  • access token を使いたい場合は getAccessToken()Server Component / Server Action / Route Handler から 呼び出す
  • import "server-only" でクライアントから誤って import するのをビルド時に検出できる
  • Cookie の httpOnly: true, sameSite: "lax", secure: true に加えて、NextAuth は CSRF トークン__Host-next-auth.csrf-token)と OAuth フローの state パラメータによる多層 CSRF 防御を行う
    • SameSite=Lax だけでは不十分(トップレベル GET ナビゲーションで Cookie が送信される、login CSRF / 一部 OAuth flow など SameSite だけでは防げない攻撃面がある)
    • NextAuth はサインイン POST に CSRF トークンを要求し、OAuth コールバックを state で照合することで、SameSite の取りこぼしをカバーしている

補足:高セキュリティ要件では refresh token をサーバストアに退避する

上記の構成では refresh token も JWE 暗号化 Cookie の中に含めて います。Cookie は HttpOnly で AUTH_SECRET がなければ復号できないので、通常用途では十分です。しかし、

  • AUTH_SECRET が漏洩した場合
  • Cookie がリプレイ攻撃 / 盗難された場合
  • Cookie が長期保存され、後年に鍵更新後も復号できる状態が問題視される場合

を考えると、refresh token のように「長期的に強い権限を持つトークン」を Cookie に同梱するのはリスクがある設計です。

高セキュリティ要件(金融・医療・管理画面など)では、以下のような構成が採用されることがあります。

  • refresh token はサーバ側ストア(Redis / RDB / KMS / Secret Manager)に格納
  • Cookie 内の JWT には「session reference id」のみを持たせる
  • access token のリフレッシュ時にサーバ側ストアから refresh token を引き当て、外部 IdP に問い合わせる
  • 失効・rotation・admin による強制取り消しがストア側で完結する

これは 先述した database strategy の発想に近く、ステートレスの旨味を一部捨てる代わりに、取り消し可能性と漏洩時の被害局所化を得る設計です。

つまり「access token は Cookie 内、refresh token はサーバストア」というハイブリッドが現実解として存在します。本記事のサンプルは現実的なデフォルトとして refresh token も Cookie に入れていますが、要件次第ではこの分離を検討してください。

Middleware で「早期リダイレクト」を行う(ただし認可の本体ではない)

認証必須なルートに対して、未認証アクセスを早期にリダイレクトして UX を改善するのに Middleware(Edge Runtime)が使えます。

// middleware.ts
import { auth } from "@/auth"

export default auth((req) => {
  const isAuthed = !!req.auth
  const isProtected = req.nextUrl.pathname.startsWith("/dashboard")

  if (isProtected && !isAuthed) {
    const url = new URL("/login", req.url)
    url.searchParams.set("callbackUrl", req.nextUrl.pathname)
    return Response.redirect(url)
  }
})

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

⚠️ Middleware を認可の本体にしてはいけない

ここは強調しておきます。Next.js Middleware は UX 改善とエッジでの早期リダイレクト には有効ですが、認可の最終防衛線にしてはいけません。理由は複数あります。

  • matcher 漏れconfig.matcher の正規表現にミスがあれば、保護されるはずのルートが素通りする
  • rewrite / redirect での迂回:内部 rewrite や別ルートからのアクセスで Middleware を経由しないケースがある
  • エッジと Node ランタイムの差異:Middleware は Edge Runtime で動くため、Node 専用の依存が使えず、検証ロジックが本体と乖離するリスク
  • 将来的な仕様変更:Next.js のレイヤー責務(Middleware / Layout / Page)は version で変動しており、ガード位置を一箇所に集中させると壊れやすい
  • 直接 API アクセス:Server Action は内部的に POST、Route Handler は HTTP 経由で叩かれる ─ Middleware で UI ルートだけ守っても、API が無防備になる事故が起きうる

したがって、最終的な認可チェックは Server Components / Server Actions / Route Handler の内部で必ず行う のが原則です。本記事のサンプルでも:

// Server Component
const session = await auth()
if (!session) return null // ← ここが「最終防衛線」
// Server Action
const session = await auth()
if (!session) throw new Error("Unauthorized") // ← ここが「最終防衛線」

のように 必ず関数内で auth() を呼んでセッションを検証 しています。Middleware はあくまで 「未認証ユーザを早めに弾いてログイン画面に飛ばす UX レイヤー」 と位置付けてください。「Middleware で守っているから本体は省略していい」という考えは典型的な落とし穴です。

Server Component から外部 API を呼ぶ

ここが 「BFF として動く」 部分です。先ほどの getAccessToken() ヘルパを使います。

// app/dashboard/orders/page.tsx
import { auth } from "@/auth"
import { getAccessToken } from "@/lib/server/access-token"
import { redirect } from "next/navigation"

export default async function OrdersPage() {
  const session = await auth()
  if (!session) redirect("/login")

  const accessToken = await getAccessToken()
  if (!accessToken) redirect("/login")

  const res = await fetch("https://api.example.com/orders", {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
    cache: "no-store",
  })
  const orders = await res.json()

  return (
    <ul>
      {orders.map((o: Order) => (
        <li key={o.id}>{o.title}</li>
      ))}
    </ul>
  )
}

未認証時は redirect("/login") でログイン画面に飛ばします(return null だと白い画面が出るだけで UX が悪い)。Middleware で先にリダイレクトされる前提ですが、最終防衛線としてここでも必ずチェックする ─ という多層防御の発想です。

accessToken 変数は このサーバプロセス内のローカル変数として一瞬存在するだけ で、レスポンスにも useSession() にも一切載りません。クライアント側("use client" コンポーネント)に props として渡してはいけないことに注意してください(渡すと RSC のシリアライズ境界を超えてブラウザに露出します)。

Server Action でミューテーション

// app/dashboard/orders/actions.ts
"use server"

import { auth } from "@/auth"
import { getAccessToken } from "@/lib/server/access-token"
import { revalidatePath } from "next/cache"

export async function createOrder(formData: FormData) {
  const session = await auth()
  if (!session) throw new Error("Unauthorized")

  const accessToken = await getAccessToken()
  if (!accessToken) throw new Error("Unauthorized")

  const res = await fetch("https://api.example.com/orders", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      title: formData.get("title"),
    }),
  })

  if (!res.ok) throw new Error("Failed to create order")
  revalidatePath("/dashboard/orders")
}

ブラウザ ↔ Next.js 間は Server Action の RPC(内部的には POST with HttpOnly Cookie)、Next.js ↔ 外部 API 間は Bearer トークンという二段構成が明確になります。access token は Server Action 関数のスコープ内にしか存在せず、戻り値にも絶対に含めません。

トークンリフレッシュ

accessToken の期限切れに備えるリフレッシュ処理です。

async function refreshAccessToken(token: JWT) {
  try {
    const res = await fetch("https://oauth2.googleapis.com/token", {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        client_id: process.env.GOOGLE_CLIENT_ID!,
        client_secret: process.env.GOOGLE_CLIENT_SECRET!,
        grant_type: "refresh_token",
        refresh_token: token.refreshToken as string,
      }),
    })
    const refreshed = await res.json()
    if (!res.ok) throw refreshed

    return {
      ...token,
      accessToken: refreshed.access_token,
      expiresAt: Math.floor(Date.now() / 1000) + refreshed.expires_in,
      refreshToken: refreshed.refresh_token ?? token.refreshToken,
    }
  } catch (error) {
    return { ...token, error: "RefreshAccessTokenError" }
  }
}

リフレッシュもサーバ側で完結します。ブラウザは「いつの間にか期限が更新されている」状態を受け取るだけです。

補足:refresh token rotation について

上のコードでは refreshed.refresh_token ?? token.refreshToken で、新しい refresh token が返ってきたら入れ替え、返ってこなければ既存を使い続ける fallback を入れています。これは refresh token rotation(一回使い切り) に対応するための実装です。

OAuth 2.0 Security Best Current Practice (RFC 9700, 2024) では、public client(SPA / BFF など)における refresh token は rotation することが推奨されています。

  • 1 回使ったら必ず新しい refresh token を発行(古いものは無効化)
  • 同じ refresh token が 2 回使われた場合、トークンファミリ全体を取り消す(漏洩検知の仕組み

外部 IdP(Auth0、Cognito、Okta、Google など)の多くは rotation をサポート/推奨しています。設定で有効化したうえで、上記のような入れ替えロジックを必ず入れてください。

このアーキテクチャが体現していること

境界 認証方式 理由
Browser ↔ BFF HttpOnly Cookie(暗号化セッション) XSS でトークン窃取耐性、SameSite=Lax + CSRF トークン + state パラメータの多層で CSRF 防御、ログアウト導線で Cookie をクライアントから削除(※サーバ側で即時失効まで必要なら database strategy を併用)
BFF ↔ 外部 API Authorization: Bearer <access_token> (JWT / OAuth2) サービス間のステートレス認証、横展開が容易
マイクロサービス間 JWT / mTLS / Service Mesh ゼロトラスト、サービスメッシュ層で透過的に検証

(4) で示した 「レイヤーごとに最適な手段を選ぶ」 という原則が、NextAuth と Next.js を素直に使うだけで自然に実現される ─ という構図です。フレームワーク側が思想を体現してくれているので、開発者は本来の関心事(ビジネスロジック)に集中できます。

「BFF を構築するのが正解なのか?」という最初の問いに戻ると、Next.js を BFF 的に使うなら(App Router + Server Components + Server Actions + Route Handlers のサーバ側機能を主軸にするなら)、すでに BFF を構築しているのと同じことです。あとはその上に乗っかる認証戦略として、徳丸氏の言う「Cookie セッションへの回帰」を素直に受け入れれば良い、という結論になります。

ただし注意点として、Next.js は 静的エクスポート / 純粋な SPA モード / クライアント主導の fetch 中心構成 でも動かせます。これらは BFF ではないので、本記事のアーキテクチャはそのままには当てはまりません。output: "export" で静的書き出ししているプロジェクトや、Server Components / Server Actions を使わず "use client" 中心で組んでいるプロジェクトでは、(2) の「ブラウザで認証情報を扱う」課題に再び向き合う必要があります。「Next.js だから自動的に BFF」ではなく、「サーバ側機能を使うことで初めて BFF になる」ことに留意してください。


参考

Discussion

htnabehtnabe

今年読んだ記事の中で一番素晴らしい記事でした。
変にマウントを取らずに真正面からJWTがどのように扱われてきたか説明されていて非常に気持ちよく読ませていただきました。
私自身、ここ数年でJWTをCookieで扱うようになった背景をいまいち理解できていなかったので、こうして具体例も踏まえて明文化いただけたのが大変ありがたかったです。「これ無料で読めていいんですか?」と思うくらい良い記事でした。ありがとうございました。

3
khalekhale

私自身2016年あたり、SPAが出た頃にこの業界に参入し、それ以前も含め改めて自分のために整理したかったものが反響がありとても励みになります。ありがとうございます。

1