JWTセキュリティを極める:XSSとCSRFを防ぐリフレッシュトークンローテーションの実装

LocalStorageにJWTを保存していませんか?その設計は、XSS攻撃一つでユーザーのセッションが完全に奪われるリスクを孕んでいます。フロントエンドの利便性を維持しつつ、エンタープライズ級の安全性を確保する手法が必要です。

この記事を読むことで、HttpOnly Cookieとリフレッシュトークンローテーション(RTR)を組み合わせ、トークン盗難を無効化する堅牢な認証システムを構築できます。

TL;DR — Access TokenはJavaScript変数(インメモリ)に、Refresh TokenはHttpOnly Cookieに保存し、トークン更新のたびにRefresh Tokenを使い捨てる「RTR」を導入するのがモダンな正解です。

1. JWTセキュリティの核心:なぜRTRが必要か

💡 イメージで理解する: 銀行の貸金庫を想像してください。Access Tokenは「30分だけ有効なパスコード」です。Refresh Tokenは「そのパスコードを再発行するための予備の鍵」です。リフレッシュトークンローテーション(RTR)は、予備の鍵を使うたびに、古い鍵を捨てて新しい鍵に交換する仕組みです。万が一古い鍵が盗まれても、すでに無効化されているため安全です。

JWT(JSON Web Token)はステートレスな認証を実現しますが、一度発行されるとサーバー側で無効化しにくいという弱点があります。最新のOAuth 2.0 BCP(Best Current Practice)では、ブラウザベースのアプリにおいてRefresh Token Rotation (RTR)の導入が強く推奨されています。現在の標準バージョンはOAuth 2.1ドラフトに基づいています。

従来の「一度発行したRefresh Tokenを長期間使い続ける」方式では、トークンが漏洩した場合に永続的なセッションハイジャックを許してしまいます。RTRを導入することで、トークンの使用履歴を管理し、不正な再利用を即座に検知できます。

2. 実務で直面する2大脅威:XSSとCSRF

フロントエンド開発者が最も警戒すべきはXSS(クロスサイトスクリプティング)です。攻撃者が悪意のあるスクリプトを注入した場合、`localStorage.getItem('token')`を実行するだけでトークンを外部サーバーに送信できてしまいます。これに対し、`HttpOnly`属性を付与したCookieはJavaScriptからアクセス不能であるため、トークン盗難を物理的に阻止します。

一方で、Cookieを使用するとCSRF(クロスサイトリクエストフォージェリ)のリスクが生じます。これは、ユーザーが意図しない操作を強制される攻撃です。現代のブラウザでは`SameSite=Strict`または`Lax`属性を適切に設定することで、このリスクを大幅に軽減できます。APIリクエスト時にカスタムヘッダー(例: `X-Requested-With`)を要求する設計も有効です。

3. ステップ別実装ガイド:RTRとHttpOnlyの設定

バックエンドとフロントエンドが連携して、安全なトークンフローを構築する手順を説明します。

ステップ 1. バックエンド:Set-Cookieヘッダーの発行

ログイン成功時、Refresh TokenをCookieにセットします。この際、`HttpOnly`, `Secure`, `SameSite=Strict`を必ず含めます。

// Node.js (Express) の例
res.cookie('refreshToken', newRefreshToken, {
  httpOnly: true,
  secure: true, // HTTPS環境必須
  sameSite: 'Strict',
  path: '/api/auth/refresh', // 更新エンドポイントのみに送信
  maxAge: 7 * 24 * 60 * 60 * 1000 // 7日間
});

ステップ 2. フロントエンド:Access Tokenのメモリ管理

Access TokenはLocalStorageではなく、アプリケーションの状態管理(ReactのStateやVuex、単なる変数)に保持します。ページをリロードすると消えますが、それは意図した挙動です。

// フロントエンドのメモリ内で保持
let accessToken = null;

const refresh = async () => {
  try {
    const response = await axios.post('/api/auth/refresh', {}, { withCredentials: true });
    accessToken = response.data.accessToken; // 新しいAccess Tokenをメモリに保存
  } catch (err) {
    accessToken = null;
    // ログイン画面へリダイレクト
  }
};

ステップ 3. RTRロジックの実装(DB側)

Refresh Tokenが使用された際、そのトークンを無効化し、新しいトークンを発行します。もし「既に使用済みのトークン」が送られてきた場合、それは盗難とみなして全セッションを強制終了させます。

// 擬似コードによる検知ロジック
const storedToken = await db.tokens.find({ token: receivedToken });

if (storedToken.isUsed) {
  // 攻撃を検知:このユーザーに関連する全リフレッシュトークンを削除
  await db.tokens.deleteMany({ userId: storedToken.userId });
  throw new Error('Security Alert: Reuse detected');
}

// 正常な更新
storedToken.isUsed = true;
await storedToken.save();
const nextToken = generateNewToken();
await db.tokens.create({ token: nextToken, userId: user.id });

4. LocalStorage vs HttpOnly Cookie 比較

保存先を決定する際の判断基準をまとめました。

基準LocalStorage (推奨しない)HttpOnly Cookie (推奨)
XSS耐性脆弱(JSで読み取り可能)強固(JSからアクセス不可)
CSRF耐性安全(自動送信されない)対策が必要(SameSite属性)
実装の容易さ非常に簡単バックエンド連携が必要
適応規模小規模・個人開発商用・エンタープライズ

セキュリティレベルを優先するなら、HttpOnly Cookieの一択です。CSRFは現在のWeb標準設定で十分に防御可能です。

5. 注意事項とトラブルシューティング

⚠️ よくあるミス: Access Tokenの有効期限を長くしすぎること。RTRを導入しても、Access Tokenが1時間も有効であれば、その間に盗まれたトークンは自由に使われてしまいます。

Access Tokenの有効期限は5分〜15分程度に設定してください。頻繁な更新が必要になりますが、サイレントリフレッシュによってユーザー体験を損なうことなく運用可能です。

エラー別の対処法

// 401 Unauthorized エラー
// 原因: Access Tokenの期限切れ、またはRefresh Tokenの不一致
// 解決: axiosのinterceptorsを使用して、401時に自動的にリフレッシュリクエストを投げるロジックを実装する

6. 実践的なセキュリティ運用チップス

セッション管理をさらに強化するために、以下の2点を推奨します。まず、リフレッシュ回数に上限を設け、一定期間(例:30日間)操作がない場合は完全にログアウトさせる構成です。これにより、DBの肥大化を防ぎます。

次に、フロントエンドでの「複数タブ」対策です。インメモリでAccess Tokenを管理すると、別タブで開いた際に再ログインが必要になります。これを防ぐには、`BroadcastChannel` APIを使用して、一つのタブで取得したAccess Tokenを他のタブへセキュアに共有する手法が有効です。

📌 まとめ

  • Access TokenはJS変数に保存し、短時間で失効させる。
  • Refresh TokenはHttpOnly/Secure Cookieで管理し、RTRを導入する。
  • トークンの再利用が検知されたら、即座に該当ユーザーの全セッションを破棄する。

よくある質問

Q. HttpOnly Cookieを使えばXSSは完全に防げますか?

A. トークンの「盗難」は防げますが、スクリプトによる「偽装リクエスト」は防げないため、CSPの設定も併用してください。

Q. SameSite=StrictとLax、どちらが良いですか?

A. セキュリティ重視ならStrictですが、外部サイトからのリンクでログインが外れるため、利便性とのバランスでLaxが一般的です。

Q. モバイルアプリ(Native)でもこの構成は必要ですか?

A. モバイルはブラウザ共有のCookie概念が異なるため、Secure Storage(Keychain等)を使用するのが標準です。

Post a Comment