- JWTをlocalStorageに保存していいのか不安
- アクセストークンとリフレッシュトークンをどこに保存すべきか知りたい
- XSSとCSRFを両方対策した設計の考え方を理解したい
JWTの保存場所は「localStorageかCookieか」と語られがちですが、アクセストークンとリフレッシュトークンで保存先を分けるのがより安全な設計です。
この記事では、なぜその設計になるのかを理由から解説します。実装コードはバックエンド・フロントエンドで別記事に分けてあるので、この記事では「設計の考え方」に絞ります。
JWTの保存場所は3種類ある
JWTを保存できる場所は主に3つあり、それぞれセキュリティ特性が異なります。
- localStorage
JavaScriptから自由に読み書きできる。XSSが成立するとlocalStorage.getItem()一行でトークンが盗まれる。 - HttpOnly Cookie
JavaScriptから読み書きできない。ただしブラウザが同一オリジンへのリクエストに自動付与するため、CSRF攻撃の対象になりやすい。 - メモリ(JS変数)
ページをまたいで持続しない。同じタブ内のJavaScriptからは参照できるが、localStorageのように永続化されないため露出範囲が限られる。
「HttpOnly Cookieが最も安全だから、全部そこに入れればいい」と思いたくなります。しかし、アクセストークンとリフレッシュトークンでは性質が大きく違うため、同じ保存先にまとめるのは最適解ではありません。
アクセストークンとリフレッシュトークンを分ける理由
2種類のトークンには、用途と有効期限に大きな差があります。
- アクセストークン(有効期限: 15分)
APIリクエストのたびにAuthorization: Bearerヘッダーに付けて送る。短命なので仮に流出しても被害期間が限定される。 - リフレッシュトークン(有効期限: 14日)
アクセストークンが切れたときだけ使う。長命なので慎重に管理する必要がある。
この性質の差から、次の方針が導き出されます。
- アクセストークン → メモリ(JS変数)
短命なのでリロードで消えても問題ない。Authorizationヘッダーで送るため、Cookieのような自動付与によるCSRFリスクがない。 - リフレッシュトークン → HttpOnly Cookie(パス: /auth/ に限定)
JavaScriptから読めないのでXSSで盗まれない。パスを/auth/に絞ることで/api/へのリクエストにはCookieが付かず、CSRF攻撃の対象面を最小化できる。
両方をHttpOnly Cookieに入れた場合と比べて、アクセストークンがヘッダー送信のみになるため、CSRFトークンによる保護が不要なリクエストの範囲が広がります。
認証フローの全体像
設計を決めたら、次はフローを整理します。ログインからAPIアクセス、トークン更新、ログアウトまでの流れです。
- ページ初期化
CSRF Cookieを取得 →/auth/jwt/refresh/にリクエスト → ブラウザがHttpOnly Cookieを自動送信 → サーバーがアクセストークンを返す → メモリに保存する - APIリクエスト
メモリ上のアクセストークンをAuthorization: Bearer {token}ヘッダーに付けて送信する - 401レスポンス時
アクセストークンの有効期限切れ →refreshToken()でトークン更新 → 新しいアクセストークンをメモリに保存 → 元のリクエストを自動リトライする - ログアウト
サーバー側でリフレッシュトークンをブラックリスト登録 → メモリ上のアクセストークンをクリアする
「メモリ保存だとページリロードでトークンが消えるのでは?」という疑問は正しくて、リロード後はステップ1の初期化フローが走ります。有効なリフレッシュCookieがあれば自動でアクセストークンが復元されます。
ROTATE_REFRESH_TOKENSが引き起こす並走リフレッシュ問題
Djangoの djangorestframework-simplejwt では ROTATE_REFRESH_TOKENS = True を設定すると、リフレッシュのたびに新しいリフレッシュトークンが発行され、古いトークンがブラックリストに登録されます。セキュリティ上望ましい設定ですが、フロントエンドで問題が起きることがあります。
ページ読み込み直後など、複数のAPIリクエストが同時に走るタイミングで、それぞれが並走して refreshToken() を呼び出すケースです。1本目のリフレッシュが成功すると古いリフレッシュトークンがブラックリストに入り、2本目以降は同じトークンで呼び出しているためエラーになります。
対策は、進行中のリフレッシュリクエストがあれば新たに呼び出さず、同じPromiseを使い回す「mutex」パターンです。シンプルな実装で解決できます。詳しくはフロントエンド実装の記事で解説します。
バックエンド(Django)の実装
SimpleJWT・Djoserの設定と、RedisキャッシュによるレートリミットミドルウェアをDjangoで実装する方法を解説しています。

フロントエンド(Next.js)の実装
アクセストークンのメモリ管理・セッションフラグ・refreshToken()のmutex・apiFetch()の自動リトライをTypeScriptで実装する方法を解説しています。


コメント