改めて考えるJWTとSession Cookie
この記事では、JWTとセッションの仕組みに対する僕の考えを書いてみたい。その前に、こちらの記事も参考にしてほしい:
JWTとSession Cookieとは何か
Session Cookie
Session Cookieは最も伝統的な認証の仕組みだ。フローはとてもシンプルである:
- ユーザーがログインすると、サーバーはランダムなSession IDを生成する
- Session IDと対応するユーザーデータはサーバー側(データベースやキャッシュ)に保存される
- サーバーは
Set-Cookieを介してSession IDをブラウザに返す - 以降のリクエストでは、ブラウザが自動的にこのCookieを付与する
- サーバーはリクエストを受信した後、Session IDを使って対応するユーザーデータを取得する
Session ID自体には何の情報も含まれておらず、単なる「鍵」に過ぎない。すべてのデータはサーバー側に保存される。
ブラウザの実行環境において、Cookieはすべてのリクエストに自動的に付与される特性があるため、実務では攻撃者によるリクエストの偽造を防ぐためにCSRFトークンを実装する必要がある。サーバーが「ログインCookieがあるかどうか」だけを見ていると、それが本人による操作だと誤認してしまう可能性がある。そのため、サーバーはCookieだけを信頼するのではなく、自社サイトのページだけが取得して一緒に送信できるトークンもあわせて検証しなければならない。
CSRFトークンとは、ログイン済みの身元を他のWebサイトが悪用して勝手にリクエストを送信するのを防ぐために、サーバーがフロントエンドに発行するランダムな検証コードだ。攻撃者は通常、ブラウザを騙してリクエストを送信させることはできても、正しいCSRFトークンを取得することはできないため、偽造されたリクエストはブロックされる。
Cookieの欠点は「リクエストが正当なものかどうかを判断できない」点にあり、そのためアプリケーション層で別途実装する必要があるのだ。
JWT(JSON Web Token)
JWTのアプローチは完全に逆で、ユーザー情報を直接トークン内にエンコードする。
JWTはHeader(アルゴリズム情報)、Payload(ユーザーデータ)、Signature(署名)の3つの部分から構成される。サーバーがJWTを発行する際、秘密鍵(secret key)を使って最初の2つの部分に署名を行う。その後JWTを受信したときは、署名を検証するだけでデータが改ざんされていないか確認でき、データベースに問い合わせる必要はない。
JWTは暗号化されているわけではない。誰でもデコードして中のデータを読むことができる。署名の役割は改ざんの防止であり、読み取りを防ぐことではない。そのため、JWTの中にパスワードや機密データを入れてはいけない。
現在の主流なアプローチ
実務において、認証のアプローチはおおむね3つに分けられる:
アプローチ1:JWT + Client Storage
JWTをクライアント側のストレージに保存する方式だ。sessionStorage、localStorage、IndexedDB、あるいはJavaScriptの変数内(インメモリ)に保持することもある。APIリクエストを送信するたびに、JavaScriptがストレージからトークンを取り出し、Authorization ヘッダーにセットする。
// 存 token
function setToken(token) {
sessionStorage.setItem('jwt', token)
}
// 發請求時帶上 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 ヘッダーを含むリクエストをトリガーできないからだ。
その代償として、httpOnly による保護を失うことになる。WebサイトにXSS脆弱性がある場合、攻撃者はトークンを直接読み取ることができてしまう。
アプローチ2:Cookie内のJWT + Refresh Token
有効期限の短いJWTを httpOnly=false のCookie(または sessionStorage)に配置して、フロントエンドがトークンの内容を読み取って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のペイロードには有効期限(exp)が含まれているため、フロントエンドはトークンが期限切れになりそうなタイミングで能動的にRefresh Tokenを要求でき、プロセス全体がバックグラウンドで完了するためユーザーは全く気づかない。「ページいっぱいにフォームを入力して送信ボタンを押したら、セッションが切れていてログインし直しになった」という事態が起きないのだ。
// 設定一個 timer,在 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の有効期限はブラウザが管理しているため、フロントエンドはセッションの残り時間を直接知ることができない。レスポンスヘッダーや専用APIを介して残り時間を返すことはできるが、これはSession Cookieに組み込まれた機能ではないため、自前で実装する必要がある。
アプローチ3:Session Cookie
前述した伝統的なやり方だ。サーバーがランダムなSession IDを生成し、Set-Cookie 経由で返し、ブラウザが自動的に処理する。
Set-Cookie: session_id=abc123; HttpOnly; Secure; SameSite=Lax; Path=/
トークン管理も、silent refreshも、署名検証もない。サーバーはリクエストを受け取った後にデータベースを1回照会し、対応するユーザーデータを取得して終了だ。
各アプローチの比較
| JWT + Client Storage | JWT + Cookie + Refresh Token | Session Cookie | |
|---|---|---|---|
| XSSリスク | トークンをJSから読み取り可能 | 短命JWTは読み取り可能、Refresh TokenはhttpOnlyで保護 | httpOnlyで保護、JSからはアクセス不可 |
| CSRFリスク | 本質的に無縁(Cookieを使わない) | SameSiteの設定が必要 | SameSiteの設定が必要 |
| 無効化の可否 | 無効化不可、期限切れを待つ | Refresh Tokenを無効化可能 | いつでも無効化可能 |
| サーバーの状態 | ステートレス(Stateless) | Refresh TokenをDBに保存する必要あり | セッションをDBまたはキャッシュに保存 |
| クロスドメイン対応 | 容易(Authorizationヘッダー) | 一部容易 | 困難(サードパーティCookieの制限) |
| 実装の複雑さ | 中 | 高(silent refresh、token rotation) | 低 |
JWTの隠れたコスト
JWTを選択した場合、考慮しなければならないコストがいくつかある。
無効化(Revocation)ができない
JWTは一度発行されると、有効期限が切れるまでは有効であり続ける。もしJWTの有効期限を1日に設定した場合、その1日の間にユーザーがログアウトしたとしても、トークンは依然として有効なままだ。1週間に設定すればさらに危険で、トークンが漏洩した際に攻撃者には丸々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.
トークンを無効化する必要が生じた瞬間(ブラックリスト方式であれRefresh Token方式であれ)、再びステートフルな世界へ逆戻りし、最終的にはトークンを管理する場所が必要になってしまうのだ。
アルゴリズムの選定
JWTは複数の署名アルゴリズムをサポートしている。もし発行と検証が同じサーバーで行われるのであれば、HMAC(HS256)で十分だ。処理が速く、キーが短く、セキュリティ強度も申し分ない。
RSAはより長いキーが必要で、検証にかかる計算コストも高い。異なるサービスがそれぞれ独自に検証する必要がある場合(公開鍵/秘密鍵の分離が必要なケース)を除けば、HMACの方が合理的な選択肢と言える。
もう一つの見落としがちな問題として、使用しているJWTライブラリが受け入れ可能なアルゴリズムを厳格に制限していない場合、攻撃者が「署名不要」(alg: none)と宣言したJWTを送信し、サーバーがそれを受け入れてしまう可能性がある。これは単なる理論上のリスクではなく、実際に発生した脆弱性だ。
シークレットキーのローテーション
署名用のシークレットキーは巨大な単一障害点(SPOF)だ。キーが漏洩した場合の結末は明白で、攻撃者は任意のユーザー、任意の権限、任意の有効期限を持つJWTをいくらでも偽造できるようになる。署名が完全に正規のものであるため、サーバーはそれを見分けることができない。
そのためキーローテーションの仕組みが必要になる。これは、複数のキーバージョンを同時にサポートしなければならないことを意味する——新しく発行されるものには新しいキーを使い、古いJWTは古いキーで検証する——そしてJWTヘッダーの kid(Key ID)は、まさにこの処理のためにある。
これらはすべて、Session Cookieであれば悩む必要のない事柄だ。Session IDは単なるランダムな文字列であり、署名も、秘密鍵も、攻撃対象となり得るアルゴリズムも存在しない。
Refresh Token:遠回りした挙句、結局データベースにアクセスする
JWTの大きなセールスポイントはステートレスであることだ。サーバーは何も保持する必要がなく、署名を検証するだけでよい。
しかし前述の通り、JWTは無効化できない。Refresh Tokenの仕組みは、まさにこの矛盾を解決するために生まれた。JWTの有効期限を極めて短く(数分間)設定して漏洩時の影響範囲を抑えつつ、長寿命のRefresh Tokenを発行してユーザーが頻繁に再ログインしなくて済むようにする。JWTが期限切れになると、クライアントはRefresh Tokenを使って新しいJWTと引き換え、ユーザーはほとんど何も気づかずに済む。
しかし、Refresh Tokenは無効化できるようにする必要があるため、サーバー側(データベースやキャッシュ)に保存しなければならない。ユーザーがログアウトしたときはRefresh Tokenを削除し、不審な挙動を検知したときはすべてのRefresh Tokenを失効させる。これらの操作はすべてデータベースへのアクセスを伴う。
フローは以下のようになる:
- 短命なJWT(数分間)を通常のリクエストに使用する——DB照会なし
- JWTが期限切れになったら、Refresh Tokenで新しいものを再取得する——DB照会あり
- Refresh Token自体を管理する必要がある——DBに保存
同時アクセスユーザー数が数万人以内であれば、Session Cookieを使って毎回データベースにアクセスするのと大きな差はない。毎回DBを叩かずに済むことで削減したはずのコストは、追加のトークン管理ロジックによって相殺されてしまうのだ。
モダンなアーキテクチャにおけるCSRFとXSS
CSRF:かつてほど恐れるものではなくなった
かつてCSRFはCookie認証における最大の難点だった。2011年にJesse Hallettが『Cookies Are Bad for You』を書いた当時、SameSite 属性はまだ存在せず、CSRFトークンの実装もバグを生みやすかった(彼は記事の中で、ステートフルなCSRFトークンがAjaxアプリケーションにおいて「a constant source of bugs(バグの絶え間ない原因)」であると言及している)。
当時、認証をCookieからJavaScriptが制御する Authorization ヘッダーへと移行したのは確かに合理的な判断だった。そして2026年の今になっても、特定の前提条件下では、JWTを使う方がSession Cookieよりもはるかにシンプルだと僕は考えている。
フロントエンドとバックエンドが分離されたアーキテクチャでは、CSRFのリスクは想像以上に低い。大半のAPIリクエストの Content-Type は application/json であり、これにより非単純リクエスト(non-simple request)となるため、ブラウザはまずプリフライトリクエストを送信する。クロスサイトの悪意あるフォームからはこの手のリクエストをトリガーできないのだ。さらに SameSite=Lax と組み合わせることで、CSRF攻撃ベクトルの大部分を遮断できる。現代のフロント・バック分離アーキテクチャにおいては、従来のCSRFトークン機構をわざわざ実装する必要さえない場合もある。
XSS:多層防御
XSS(クロスサイトスクリプティング)とは、攻撃者がWebページ内に悪意のあるJavaScriptを注入し、他のユーザーがそのページを閲覧した際にブラウザ上でそのスクリプトが実行されてしまう攻撃のことだ。どちらの方式を採用するにせよ、XSSは常に向き合わなければならないリスクである。違いは、XSSが発生した後の影響範囲にある。
httpOnly Cookieは攻撃者にトークンを丸ごと持ち去られるのを防ぐことはできるが、攻撃者が被害者のブラウザを直接利用してリクエストを送信したりスクリプトを挿入したりすることまでは阻止できない。Huli氏から得た気づきの一つだが、httpOnly の役割は漏洩の範囲を限定することであって、XSSそのものを防御することではないのだ。
本質的な防衛線は、そもそもXSSを発生させないことにある:
- モダンなフロントエンドフレームワーク(React、Vue、Svelteなど)は、デフォルトで出力をエスケープする
- 厳格なCSP(Content Security Policy)によって
script-srcの取得元を制限し、インラインスクリプトを禁止することで、XSS攻撃の成功率をさらに抑え込める
もし万が一XSSが発生してしまった場合でも、トークンの有効期限を短くしておくことで被害範囲を抑えられる。Access Tokenを5〜15分、Refresh Tokenを30〜60分程度にしておくのだ。攻撃者がたとえAccess Tokenを入手できたとしても、利用可能な猶予は極めて短い。そしてRefresh Tokenが httpOnly Cookieにあれば、攻撃者はそれを取得することすらできず、自身の環境でAccess Tokenを再生成することも不可能になる。
もし httpOnly=false が受け入れられないと考えるなら、トークンを localStorage に置くことも受け入れるべきではない。なぜなら、どちらもXSSに対するリスクはまったく同一だからだ。
どんな時にJWTが必要になるのか?
JWTが真の価値を発揮する場面は以下の通りだ:
- 複数のマイクロサービスが独自に認証を行う必要がある場合:API GatewayがJWTを発行し、下流のサービスがそれぞれ公開鍵で検証することで、全サービスが認証DBへ問い合わせに行く必要がなくなる
- クロスドメインのシングルサインオン(SSO):異なるドメインのサービス間で認証状態を共有する必要がある場合
- 大規模なトラフィック:同時接続ユーザー数が多すぎて、セッション照会がパフォーマンスのボトルネックになる場合
サービスがクロスドメインを必要とする場合、JWTは確かに扱いやすい。ブラウザによるサードパーティCookieへの制限は厳しさを増している——ChromeはサードパーティCookieの段階的な廃止を予定しており、Safariではとっくにデフォルトでブロックされている。この流れの中では、Cookieを通じてドメインをまたいで身元を受け渡すことはますます信頼できなくなっている。Authorization ヘッダーを介して伝達するJWTなら、これらの制限を一切受けない。
認証・会員システムを自作してはいけない
JWTとSession Cookieのどちらを選ぶとしても、認証システムの実装は細部でミスを犯しやすい。パスワードのハッシュ化、トークンの有効期限処理、Refresh Tokenのローテーション、複数端末でのログアウト、ブルートフォース攻撃への対策などだ。どのステップにもセキュリティリスクが潜んでおり、ほとんどの場合はゼロから自作する必要はない。
既存のソリューションはすでに非常に成熟している:
- Auth.js(旧NextAuth.js):多様なフレームワークに対応し、ビルトインのOAuth連携やセッション管理をすぐに利用できる
- Supabase Auth:すでにSupabaseを使っているなら直接統合でき、OAuthやマジックリンクにも対応
- Clerk、Auth0:フルマネージドの認証サービスで、自前で認証インフラを保守したくないチームに最適
これらのツールがOAuthフロー、セッション管理、トークンローテーションといった詳細をすべて肩代わりしてくれる。僕たちはプロダクトのロジックに集中すればいいのだ。
認証方式を選ぶ際の本質的な問いは1つだけだ。「実際の要件は何か」ということである。モノリス構成、同一ドメイン、ユーザー規模もそこまで大きくないのであれば、Session Cookieで十分だ。クロスドメイン、マイクロサービス、あるいは大規模トラフィックの要件があって初めて、JWTはその付随する複雑さに見合う価値を発揮する。シンプルな選択肢だからといってプロフェッショナルでないわけではない。選ぶべき複雑さは、実際の要件に見合ったものであるべきなのだ。
関連記事
- AIと踊る ChatGPT 3.5からClaude Codeまで、ソフトウェア開発は3年足らずの間に劇的な変貌を遂げた。一人のソフトウェアエンジニアによる、この変革の渦中における観察、思索、そして葛藤。
- 2026年、AWSはもういらないかもしれない クラウドプラットフォームを選ぶ前に、チームがAWSに支払っている真の代償をまずはっきり計算してみよう
- なぜサービスデプロイに ECS を使うべきなのか AWS上でコンテナサービスを動かす際、なぜECSがEC2やEKSよりも現実的な選択肢なのか、そしてデプロイの複雑さがいかにコストを蝕むのか
- ソフトウェアエンジニアリングの幻滅、再び LLMモデルの大幅な進化によってソフトウェア開発のあり方は完全に変わり、アプリケーションを1本作るのにほとんど参入障壁がなくなった