トークン保存のセキュリティ
このドキュメントの目的
OAuth/OIDCで発行されるトークン(Access Token、Refresh Token、ID Token)をどこに保存すべきか、各方式のセキュリティリスクと対策を学びます。
RFC 10017(OAuth 2.0 for Browser-Based Applications、BCP 212) は、悪意ある JavaScript は正規のコードと同じ権限を持つことを出発点に置いています。正規のコードがトークンを読めるなら、攻撃者も読めます。
保存場所の選択は、XSS が起きた場合の被害を減らしません。減らせるのは「トークンをブラウザに置かない」ことだけです。
このドキュメントは保存場所ごとの性質を整理したものです。アーキテクチャの選択が先で、その結論は上記の RFC を参照してください。
なぜトークン保存が重要なのか
OAuth/OIDCでは、認証後にトークンが発行される。
このトークンは「入場パス」のようなもの。
トークンを持っている = その人として扱われる
つまり、トークンが盗まれると:
- ユーザーのデータを閲覧される
- ユーザーとして操作される
- 長期間なりすま される(Refresh Tokenの場合)
だから「どこに保存するか」が重要。
ブラウザでの保存場所と攻撃の関係
┌─────────────────────────────────────────────────────────────┐
│ ブ ラウザ │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ localStorage │ │sessionStorage│ │ Cookie │ │
│ │ │ │ │ │ │ │
│ │ XSSで │ │ XSSで │ │ HttpOnlyなら │ │
│ │ 盗める ✗ │ │ 盗める ✗ │ │ 盗めない ◎ │ │
│ └─────────────┘ └─────────────┘ └──────┬──────┘ │
│ │ │
│ ┌─────────────┐ │ │
│ │ メモリ │ │ │
│ │ (変数) │ ←── 永続化はされないが │ │
│ │ │ XSS では読める ✗ │ │
│ └─────────────┘ │ │
│ │ │
└─────────────────────────────────────────────┼───────────────┘
│
CSRFで勝手に送信されるリスク △
トークンの種類と機密性
| トークン | 用途 | 有効期限 | 機密性 |
|---|---|---|---|
| Access Token | APIアクセス | 短期(1時間) | 高 |
| Refresh Token | Access Token更新 | 長期(30日) | 非常に高 |
| ID Token | ユーザー認証証明 | 短期(5分) | 中 |
保存場所の選択肢
比較表
| 保存場所 | XSS で読めるか | 永続性 | CSRF耐性 | 使いやすさ |
|---|---|---|---|---|
| メモリ(変数) | 読める | リロードで消える | ◎ | △ |
| HttpOnly Cookie | 読めない | 属性次第(Max-Age / Expires 無しならブラウザを閉じると消える) | △ | ○ |
| localStorage | 読める | 永続 | ◎ | ◎ |
| sessionStorage | 読める | タブ内で永続 | ◎ | ○ |
HttpOnly Cookie は JavaScript から読めませんが、攻撃者はブラウザにリクエストを送らせるだけで Cookie を使えます(RFC 10017 §5.1.4)。読めないことと悪用できないことは別です。
メモリは永続化されないという性質が価値であって、XSS への防御ではありません。
1. メモリ保存
概要
// トークンをJavaScript変数として保持
let accessToken = null;
async function login() {
const response = await fetch('/oauth/token', { ... });
const data = await response.json();
accessToken = data.access_token; // メモリに保存
}
async function callApi() {
const response = await fetch('/api/resource', {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
}
メリット
✅ 永続化されない
- ブラウザを閉じればトークンは消える
- ディスクに残らないため、後から取り出される経路が無い
✅ CSRF攻撃に対して安全
- Cookieを使用しないため、自動送信されない
「クロージャに入れれば外部スクリプトからアクセスできない」という説明を見かけますが、誤りです。
攻撃者のコードは正規のコードと同じコンテキストで動きます。正規のコードがトークンを使ってリクエストを送れる以上、攻撃者は同じ関数を呼ぶか、fetch を差し替えるか、リクエストを傍受すれば到達できます。クロージャは別オリジンからの保護であって、同一コンテキストで動くコードへの障壁ではありません。
メモリ保存の価値は「XSS で読めないこと」ではなく「永続化されないこと」です。
デメリットと対策
❌ ページリロードでトークン消失
対策: サイレントリフレッシュ(iframe + Refresh Token Cookie)
❌ 新しいタブでログイ ン状態が共有されない
対策: BroadcastChannel API、SharedWorker
❌ Refresh Tokenの保存場所が別途必要
対策: HttpOnly Cookieで保存
推奨パターン
Access Token: メモリ
Refresh Token: HttpOnly Cookie(BFF経由)
2. HttpOnly Cookie
概要
Set-Cookie: refresh_token=xyz789;
HttpOnly;
Secure;
SameSite=Strict;
Path=/oauth;
Max-Age=2592000
メリット
✅ XSS攻撃に対して安全
- JavaScriptからアクセス不可(HttpOnly属性)
✅ 自動的にリクエストに含まれる
- API呼び出し時に明示的な処理不要
デメリットと対策
❌ CSRF攻撃のリスク
対策: SameSite属性(Strict/Lax)、CSRFトークン
❌ クロスオリジンリクエストで制限
対策: 同一オリジンのBFF(Backend For Frontend)パターン
3. localStorage
概要
// 保存
localStorage.setItem('access_token', accessToken);
// 取得
const token = localStorage.getItem('access_token');
// 削除
localStorage.removeItem('access_token');
メリット
✅ 永続的な保存(ブラウザを閉じても維持)
✅ 使いやすいAPI
✅ CSRF攻撃に安全(Cookieではないため)
デメリット(重大)
❌ XSS攻撃に対して脆弱
- JavaScriptから直接アクセス可能
- 悪意のあるスクリプトが実行されるとトークンが盗まれる
XSS攻撃でトークンが盗まれる具体的な流れは 入力バリデーション - XSS攻撃シミュレーションを参照。
使用すべき場合
- 機密性の低いデータのみ
- XSS対策が十分に実装されている場合(CSP等)
- 短期間のAccess Tokenのみ(Refresh Tokenは不可)
結局どうすればいいのか?
ここまで見てきた保存場所には、それぞれ弱点が ある。
┌─────────────────┬────────────────────────────────────┐
│ 保存場所 │ 弱点 │
├─────────────────┼────────────────────────────────────┤
│ メモリ │ ページリロードで消える │
│ │ タブ間で共有できない │
├─────────────────┼────────────────────────────────────┤
│ HttpOnly Cookie │ SPAからAPIを直接呼ぶ時に不便 │
│ │ CSRFリスクがある │
├─────────────────┼────────────────────────────────────┤
│ localStorage │ XSSで盗まれる │
│ │ Refresh Tokenは保存禁止 │
└─────────────────┴────────────────────────────────────┘
→ 単独では完璧な方法がない
→ そもそもブラウザにトークンを置かない(BFF)が第一選択
上の表は「ブラウザにトークンを置く」ことを前提にした比較です。RFC 10017 は、その前提そのものを見直すことを勧めています。
- BFF — ブラウザには Cookie のみ。トークンはバックエンドが持つ
- Token-Mediating Backend — アクセストークンだけ渡し、リフレッシュトークンは渡さない
- ブラウザのみのクライアント — すべての攻撃シナリオに晒される
アーキテクチャの選択については RFC 10017 を参照してください。
BFFパターン
BFF は、保存場所の弱点を補う手段ではなく、ブラウザにトークンを渡さないアーキテクチャです。RFC 10017 では3つのアーキテクチャの筆頭に置かれています。
RFC が定義する BFF の3つの責務:
- confidential クライアントとして認可サーバーとやり取りする
- トークンを Cookie ベースのセッションの文脈で管理し、ブラウザに露出させない
- リソースサーバーへのすべてのリクエストを中継し、アクセストークンを付与する
BFF はサーバーサイドで動きますが、フロントエンドアプリケーションの構成要素です。RFC は「BFF がフロントエンドアプリケーションの OAuth クライアントになる」と明記しています。リバースプロキシや API Gateway と混同しないよう注意が必要です。
BFF でも防げないものがあります。攻撃者はブラウザから BFF にリクエストを送れるため、RFC 10017 §5.1.4(ブラウザ経由のプロキシ)は残ります。HttpOnly Cookie を使うことで、セッション状態への直接アクセスを防ぎ、client hijacking から session hijacking への昇格は止められます。
ただし、BFFにもトレードオフがある。
BFFパターンのトレードオフ:
メリット デメリット
─────────────────────────────────────────────────
✅ トークンをSPAに持たせない ❌ 実装が複雑になる
✅ XSSでトークンを盗まれない ❌ レイテンシが増加
✅ 新規トークンの取得も防げる ❌ サーバー運用コスト増
(confidentialクライアント) ❌ BFF自体が攻撃対象になる
△ ブラウザ経由のプロキシは残る
→ セキュリティ要件とコストのバランスで判断
SPAとBFFをセッションで繋ぐことの注意点
BFFパターンでは、SPAとBFFの間をセッションCookieで繋ぐことが多い。
┌───────┐ セッションCookie ┌───────┐
│ SPA │ ←────────────────→ │ BFF │
└───────┘ (HttpOnly) └───────┘
これはOAuthトークン管理の複雑さを、セッション管理の複雑さに置き換えているとも言える。
セッション管理の詳細(攻撃対策、Cookie属性、分散環境での課題)については セッションセキュリティを参照。
パターンA: BFFがAPI代理呼び出し(より安全)
┌───────┐ ┌───────┐ ┌───────┐
│ SPA │ │ BFF │ │ API │
└───┬───┘ └───┬───┘ └───┬───┘
│ │ │
│ 1. リクエスト │ │
│ (セッションCookie) │ │
│───────────────────────>│ │
│ │ │
│ │ 2. API呼び出し │
│ │ (Access Token) │
│ │───────────────────────>│
│ │ │
│ │ 3. レスポンス │
│ │<───────────────────────│
│ │ │
│ 4. データ返却 │ │
│<───────────────────────│ │
BFFが保持:
- Access Token(メモリ/セッション)
- Refresh Token(HttpOnly Cookie)
SPAが保持:
- セッションCookie のみ(トークンなし)
パターンB: BFFがトークン管理のみ
┌───────┐ ┌───────┐ ┌───────┐
│ SPA │ │ BFF │ │ API │
└───┬───┘ └───┬───┘ └───┬───┘
│ │ │
│ 1. トークン取得/更新 │ │
│ (セッションCookie) │ │
│───────────────────────>│ │
│ │ │
│ 2. Access Token │ │
│<───────────────────────│ │
│ │ │
│ 3. API呼び出し(直接) │
│ (Access Token) │
│────────────────────────────────────────────────>│
│ │ │
│ 4. レスポンス │
│<────────────────────────────────────────────────│
BFFが保持:
- Refresh Token(HttpOnly Cookie)
SPAが保持:
- Access Token(メモリ)
どちらを選ぶか
| 観点 | パターンA | パターンB |
|---|---|---|
| セキュリティ | ◎ SPAにトークンなし | ○ Access Tokenのみ |
| 実装複雑度 | △ BFFでAPI代理が必要 | ○ トークン管理のみ |
| レイテンシ | △ BFF経由で増加 | ◎ 直接通信 |
| 推奨 | 金融系など高セキュリティ | 一般的なWebアプリ |
XSS が起きたとき、保存場所で何が変わるか
保存場所によって変わるのは「攻撃者がトークンをどう取 るか」であって、「取れるかどうか」ではありません。
| 保存場所 | 攻撃者の取り方 |
|---|---|
| localStorage / sessionStorage | localStorage.getItem() で直接読む |
| メモリ(変数) | 正規コードの関数を呼ぶ/fetch を差し替えて送信時に傍受する |
| HttpOnly Cookie | 読めないが、リクエストを送らせれば Cookie は自動で付く |
いずれも、攻撃者はトークンを使えます。
さらに RFC 10017 は、保存場所に一切依存しない攻撃を2つ挙げています。
| シナリオ | 内容 |
|---|---|
| §5.1.3 新しいトークンの取得 | 隠し iframe でサイレントに認可フローを回し、アプリが持つものとは別のトークンを発行させる |
| §5.1.4 ブラウザ経由のプロキシ | トークンを盗まず、正規のアプリと同じようにリソースサーバーを叩く |
保存場所を変えても、この2つは塞がりません。
→ XSS 対策(CSP・出力エンコード・SRI 等)は必須です。保存場所の選択はその代わりにはなりません。詳しくは入力バリデーションと RFC 10017 を参照してください。
チェックリスト
Web SPA
まずアーキテクチャを決めます。
- BFF を採用(ブラウザにトークンを渡さない)
- BFF が難しい場合、最低限 Refresh Token はブラウザに渡さない(Token-Mediating Backend)
ブラウザにアクセストークンを置く場合:
- Access Tokenはメモリに保存(XSS 対策ではなく、永続化を避けるため)
- アクセストークンの有効期間を短くする
- スコープを必要最小限にする
- DPoP 等の sender-constrained token を検討する(ブラウザのみのクライアントの制約は解決しないが、盗用の難度は上がる)
- CSRF対策を実装
- CSP(Content Security Policy)を設定
共通
- Access Tokenは短い有効期限(1時間以下)
- Refresh Tokenは適切な有効期限
- トークン失効機能を実装
発展: モバイルアプリでのトークン保存
この内容は発展的なトピックです。Webアプリの基礎を理解してから学習することを推奨します。
iOS
Keychainに保存(推奨)
- kSecAttrAccessibleWhenUnlockedThisDeviceOnly
- デバイス固有、バックアップ対象外
Android
EncryptedSharedPreferencesを使用(推奨)
- AES256-GCM暗号化
- Android Keystore連携