· 13分で読了

JWT と Session Cookie をあらためて考える

# ソフトウェアエンジニアリング
この記事は中国語から自動翻訳されたものです。翻訳によりニュアンスが失われている場合があります。

この記事では僕の JWT と session の仕組みに対する考えを話す。その前に、まずはこの記事を参照してほしい。

session cookie は最も伝統的な認証メカニズムだ。流れはとても単純だ。

  1. ユーザーがログインし、サーバーがランダムな session ID を生成する
  2. Session ID と対応するユーザー情報をサーバー側(データベースまたはキャッシュ)に保存する
  3. サーバーが Set-Cookie で session ID をブラウザに返す
  4. 以後、リクエストのたびにブラウザが自動でこの cookie を付与する
  5. サーバーはリクエストを受け取ると、Session ID から対応するユーザー情報を引く

Session ID そのものには何の情報も含まれていない。ただの鍵だ。すべてのデータはサーバー側にある。

ブラウザの実行環境では、Cookie は毎回のリクエストに自動で付与される。そのため実務では、攻撃者によるリクエストの偽造を防ぐために CSRF Token を実装する必要がある。サーバーが「ログイン cookie があるか」だけを見ていると、これは本人の操作だと誤認してしまう可能性がある。だからサーバーは cookie だけを信用してはならず、自分のサイトのページだけが取得して一緒に送れる token も確認しなければならない。

CSRF token は、サーバーがフロントエンドに渡すランダムな検証コードで、別のサイトがログイン済みの身分を悪用して密かにリクエストを送るのを防ぐためのものだ。攻撃者は通常、ブラウザにリクエストを送らせることはできるが、正しい CSRF token は手に入れられない。そのため偽造リクエストは遮断される。

Cookie の欠点は「リクエストが合法かどうかを判断できない」ことだ。だからアプリケーション層で別途実装する必要がある。

JWT(JSON Web Token)

JWT の考え方はまったく逆で、ユーザー情報を直接 token にエンコードする。

JWT は Header(アルゴリズム情報)、Payload(ユーザーデータ)、Signature(署名)の 3 部分から成る。サーバーが JWT を発行する際、秘密鍵で前半 2 部分に署名する。その後 JWT を受け取ったら、署名を検証するだけでデータが改ざんされていないことを確認でき、データベースを引く必要がない。

JWT は暗号化ではない。誰でも decode して中身を読める。署名の役割は改ざん防止であって、閲覧防止ではない。だから JWT にパスワードや機密情報を入れてはいけない。

現在主流の方案

実務では、認証のやり方は大きく 3 つに分けられる。

方案一:JWT + Client Storage

JWT をクライアント側の保存領域に置く。sessionStoragelocalStorageIndexedDB、あるいは JavaScript の変数の中(in-memory)でもよい。API リクエストを送るたびに、JavaScript が保存領域から token を取り出して Authorization header に入れる。

// トークンを保存
function setToken(token) {
  sessionStorage.setItem('jwt', token)
}

// リクエスト時にトークンを付与
async function fetchWithAuth(url, options = {}) {
  const token = sessionStorage.getItem('jwt')
  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      'Authorization': `Bearer ${token}`,
    },
  })
}

この方案の特徴は、認証を完全に JavaScript が制御し、ブラウザの cookie 機構に依存しないことだ。2011 年に Jesse Hallett は Cookies Are Bad for You でこの考えを提示している。

The key is to choose a mechanism that is controlled by the web application, not the browser.

認証をブラウザ機構(cookie)からアプリケーション層(JavaScript)へ移すことで、CSRF 攻撃はこの構成では成立しない。悪意あるサイトは <form><img> を使って Authorization header 付きのリクエストを発生させられないからだ。

代償は httpOnly の保護を失うことだ。サイトに XSS 脆弱性があれば、攻撃者は token を直接読み取れる。

短命の JWT を httpOnly=false の cookie(または sessionStorage)に置き、フロントエンドが token の内容を読んで UI 状態を判断できるようにする。同時に、長命の refresh token を httpOnly=true の cookie に置き、漏洩リスクを下げる。

Hasura の JWT Best Practices が推奨しているのもこの構成で、silent refresh 機構を組み合わせる。

// JWT が期限切れになる前に自動で更新する
function isTokenExpired(token) {
  const claims = JSON.parse(atob(token.split('.')[1]))
  return claims.exp * 1000 < Date.now()
}

async function refreshToken() {
  // refresh token は httpOnly cookie の中にあり、ブラウザが自動で付与する
  const response = await fetch('/auth/refresh', {
    method: 'POST',
  })
  const { jwt } = await response.json()
  sessionStorage.setItem('jwt', jwt)
  return jwt
}

async function fetchWithAuth(url, options = {}) {
  let token = sessionStorage.getItem('jwt')
  if (!token || isTokenExpired(token)) {
    token = await refreshToken()
  }
  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      'Authorization': `Bearer ${token}`,
    },
  })
}

この方案は、セキュリティと柔軟性のバランスを取ろうとするものだ。短命 JWT が漏洩時の影響範囲を制限し、refresh token の httpOnly が長期認証情報を保護する。

SPA を中心とするアプリケーションでは、この構成はユーザー体験の面で明らかな利点がある。JWT の payload に有効期限(exp)が入っているため、フロントエンドは token が切れそうになったら自動で refresh token を使って更新できる。処理はバックグラウンドで完了し、ユーザーはまったく意識しない。「長いフォームを全部入力して送信したら、session が切れていて再ログインが必要だった」という事態を避けられる。

// JWT の期限 1 分前に自動で refresh するタイマーを設定
function scheduleTokenRefresh(token) {
  const claims = JSON.parse(atob(token.split('.')[1]))
  const expiresIn = claims.exp * 1000 - Date.now()
  const refreshAt = expiresIn - 60 * 1000 // 期限 1 分前

  if (refreshAt > 0) {
    setTimeout(async () => {
      const newToken = await refreshToken()
      scheduleTokenRefresh(newToken)
    }, refreshAt)
  }
}

Session cookie でこれを実現するには追加の設計が必要だ。Cookie の有効期限はブラウザが管理するため、フロントエンドは session があとどれくらい残っているかを直接知ることができない。response header や専用 API で残り時間を返すことはできるが、これは session cookie に内蔵された能力ではなく、自分で実装する必要がある。

これが前述の伝統的なやり方だ。サーバーがランダムな session ID を生成し、Set-Cookie で返し、ブラウザが自動処理する。

Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax; Path=/

token 管理も、silent refresh も、署名検証もない。サーバーはリクエストを受けるたびに一度データベースを引き、対応するユーザー情報を見つけて終わりだ。

各方案の比較

JWT + Client StorageJWT + Cookie + Refresh TokenSession Cookie
XSS リスクToken を JS で読める短命 JWT は読めるが、refresh token は httpOnly で保護されるhttpOnly 保護あり、JS から触れない
CSRF リスクもともと免疫あり(cookie を使わない)SameSite の併用が必要SameSite の併用が必要
撤回能力撤回できない、期限切れを待つのみrefresh token を撤回できるいつでも撤回できる
サーバー状態StatelessRefresh token を DB に保存する必要があるSession を DB またはキャッシュに保存する
クロスドメイン対応容易(Authorization header)ある程度容易難しい(サードパーティ cookie の制限)
実装複雑度高い(silent refresh, token rotation)低い

JWT の隠れたコスト

JWT を選ぶなら、いくつかのコストも合わせて考えなければならない。

撤回できない

JWT は一度発行されると、期限が切れるまでは有効だ。JWT の有効期限を 1 日に設定したなら、その 1 日のあいだはユーザーがログアウトしても token はまだ有効だ。1 週間にするとさらに危険で、token が漏れた後、攻撃者には丸 1 週間の猶予がある。

Hasura の記事の提案は、JWT の有効期限を 5〜15 分に抑え、refresh token で silent refresh することだ。ただし彼ら自身も認めている通りだ。

Token deny-listing introduces central state again, and brings us back to what we had before using JWTs at all.

token を撤回する必要があるなら(ブラックリストであれ refresh token であれ)、結局 stateful な世界に戻る。最終的には、token を管理する場所が必要になるのだ。

アルゴリズムの選定

JWT は複数の署名アルゴリズムをサポートする。発行と検証が同じサーバー上で完結するなら、HMAC(HS256)で十分だ。高速で、鍵も短く、十分な安全性がある。

RSA はより長い鍵を必要とし、検証時の計算コストも高い。複数のサービスにそれぞれ検証させる必要がある場合(公開鍵/秘密鍵を分離する場面)でなければ、HMAC のほうが合理的だ。

もう一つ見落としやすい問題がある。JWT library が受け入れるアルゴリズムを厳密に制限していない場合、攻撃者は「署名不要」(alg: none)だと主張する JWT を送り込み、サーバーがそれを受け入れてしまう可能性がある。これは理論上の話ではなく、実際に起きた脆弱性だ。

Secret key rotation

署名に使う鍵は巨大な単一障害点だ。鍵が漏洩した場合の結果は、攻撃者があらゆる JWT を偽造できることだ。どのユーザーでも、どの権限でも、どの有効期限でも偽造できる。署名が完全に正当なので、サーバーは見分けられない。

鍵ローテーションの仕組みが必要になる。つまり複数の鍵バージョンを同時に扱わなければならない。新しく発行する JWT は新しい鍵で、古い JWT は古い鍵で検証する。そのために JWT header の kid(Key ID)がある。

これらは session cookie では悩まなくてよい。Session ID は単なるランダム文字列であり、署名も鍵もアルゴリズムも、攻撃対象になるものは何もない。

Refresh token は回り道をして、結局データベースに当たる

JWT の大きな売りは stateless であることだ。サーバーは何も保存せず、署名を検証するだけでよい。

だが前述の通り、JWT は撤回できない。Refresh token 機構はこの矛盾を解決するためのものだ。JWT の有効期限を非常に短くし(数分)、漏洩時の影響範囲を小さくする。同時に長命の refresh token を発行し、ユーザーが頻繁に再ログインしなくて済むようにする。JWT が切れたら、クライアントは refresh token で新しい JWT に交換し、ユーザーはほぼ意識しない。

しかし refresh token はサーバー側(データベースまたはキャッシュ)に存在していなければならない。撤回できる必要があるからだ。ユーザーがログアウトしたら refresh token を削除するし、異常行動を検知したらすべての refresh token を失効させる。こうした操作にはデータベース参照が必要になる。

流れはこうなる。

  1. 短命 JWT(数分)を通常リクエストに使う——データベースは引かない
  2. JWT 期限切れ後、refresh token で新しい JWT を得る——データベースを引く
  3. Refresh token 自体を管理する——データベースに保存する

同時オンラインユーザーが数万人程度なら、これと毎回 session cookie でデータベースを引く方式との差は大きくない。毎回 DB を引かなくて済むコストは、追加の token 管理ロジックに食われる。

現代アーキテクチャにおける CSRF と XSS

CSRF:昔ほど怖くない

CSRF はかつて cookie 認証の最大の弱点だった。2011 年に Jesse Hallett が Cookies Are Bad for You を書いた当時は、SameSite 属性がまだ存在せず、CSRF token の実装もバグを生みやすかった(彼は本文で、stateful な CSRF token は Ajax アプリにおいて「a constant source of bugs」だと述べている)。

当時、認証を cookie から JavaScript 制御の Authorization header に移すのは確かに合理的な選択だった。2026 年の今でも、いくつかの前提があるなら、JWT のほうが Session Cookie よりずっと単純だと僕は考える。

フロントエンドとバックエンドが分離された構成では、CSRF のリスクは想像ほど高くない。大半の API リクエストの Content-Typeapplication/json であり、これは non-simple request になる。するとブラウザは先に preflight リクエストを送るため、クロスサイトの悪意あるフォームではこの種のリクエストを発生させられない。さらに SameSite=Lax を組み合わせれば、ほとんどの CSRF 攻撃ベクトルを防げる。現代のフロントエンド/バックエンド分離アーキテクチャでは、従来の CSRF token 機構を実装しなくてもよい場合すらある。

XSS:多層防御

XSS(Cross-Site Scripting)とは、攻撃者があなたのページに悪意ある JavaScript を注入し、他のユーザーがそのページを見ると、そのスクリプトがそのユーザーのブラウザで実行される攻撃だ。どの方案を選んでも、XSS は向き合うべきリスクだ。違いは、XSS が起きた後の影響範囲にある。

httpOnly cookie は攻撃者が token を丸ごと持ち出すのを防げるが、攻撃者が被害者のブラウザで直接リクエストを送ったり、スクリプトを差し込んだりするのは防げない。Huli から得た気づきの一つは、httpOnly の役割は漏洩範囲を制限することであって、XSS 自体を防ぐことではないという点だ。

本当の防衛線は、XSS を起こさないことだ。

  • 現代のフロントエンドフレームワーク(React、Vue、Svelte)は、デフォルトで出力を escape する
  • 厳格な CSP(Content Security Policy)で script-src の送信元を制限し、inline script を禁止すれば、XSS 攻撃の成功率をさらに下げられる

それでも XSS が起きてしまったなら、token の有効期限を短くすることで被害範囲を抑えられる。Access token は 5〜15 分、refresh token は 30〜60 分程度にする。攻撃者が access token を手に入れても、使える時間は非常に短い。refresh token を httpOnly cookie に置いていれば、攻撃者はそもそもそれを取得できず、自分の環境で access token を再生成することもできない。

もし httpOnly=false を受け入れられないなら、localStorage に token を置くことも受け入れるべきではない。XSS に対するリスクは両者でまったく同じだからだ。

どんなときに JWT が必要か

JWT が本当に価値を発揮する場面は次の通りだ。

  • 複数のマイクロサービスが独立して認証を検証する必要がある: API Gateway が JWT を発行し、下流サービスがそれぞれ公開鍵で検証する。各サービスが認証 DB を引く必要がない
  • クロスドメインのシングルサインオン(SSO): 異なるドメインのサービス間で認証状態を共有する必要がある
  • 大規模トラフィック: 同時オンラインユーザーが多すぎて、session 検索が性能ボトルネックになる

サービスがクロスドメインである必要があるなら、JWT は確かに便利だ。ブラウザの第三者 cookie に対する方針は年々厳しくなっている。Chrome は第三者 cookie を段階的に廃止する予定で、Safari はすでにデフォルトでブロックしている。この流れでは、クロスドメインで cookie を使って認証を渡すのはますます信頼できなくなる。JWT を Authorization header で渡すなら、こうした制約の影響を受けない。

会員システムは自分で作るな

JWT であれ session cookie であれ、認証システムの実装詳細はとてもミスしやすい。パスワードハッシュ、token の期限切れ処理、refresh token rotation、複数端末からのログアウト、ブルートフォース対策。どの段階にもセキュリティリスクがあり、多くの場合は最初から自作する必要はない。

既成の解決策はすでに成熟している。

  • Auth.js(旧 NextAuth.js): 複数フレームワークをサポートし、OAuth 連携と session 管理を標準搭載している
  • Supabase Auth: すでに Supabase を使っているなら、auth を直接統合でき、OAuth と magic link をサポートする
  • ClerkAuth0: 完全ホスト型の認証サービスで、認証基盤を自分で維持したくないチームに向いている

これらのツールは、OAuth flow、session 管理、token rotation などの細部を肩代わりしてくれる。僕らはプロダクトロジックに集中すればよい。


認証方式を選ぶときの核心はただ一つ、自分たちの実際の要件は何か、だ。モノリシック構成で、同一ドメインで、ユーザー規模も大きくないなら、session cookie で十分だ。クロスドメイン、マイクロサービス、大規模トラフィックの要件があるなら、その追加複雑性に見合うだけ JWT の価値がある。単純な方案が非専門的だというわけではない。選ぶ複雑さは、実際の要件に合わせるべきなのだ。

関連記事

他のトピックを探索