はじめに
こんにちは、NTTドコモ情報システム部の山川です。
近年、クラウド前提でシステムを構築するケースが一段と増え、国内外でもAWSのマネージドサービスを活用した構成が一般的になってきました。バックエンドはサーバーレス、フロントはSPA/SSRといったアーキテクチャが定着し、認証にはCognitoを採用する例も珍しくありません。
この流れは「早く作る・安全に運用する」を両立させるためですが、その一方で、ライブラリやサービスの“デフォルト挙動”が想定とズレたまま本番に乗るヒヤリハットも起きがちです。今回のテーマは、その例の1つである「トークンの置き場所」を設計段階でどう扱うか、という話になります。
AWS Cognitoは多くのプロジェクトで使われていますが、SDKのデフォルトの挙動まで意識しているケースは、意外と少ないのではないでしょうか。
今回は、社内システムの運用管理者向けの新規画面を作るにあたって、認証トークンの取り扱いを事前に検討し、ヒヤリハットになり得るポイントを設計段階で潰した話を共有します。具体的には、amazon-cognito-identity-js(AmplifyのJSライブラリのひとつ)が認証トークンをブラウザのlocalStorageに保存するデフォルト仕様と、その挙動を意図通りに切り替える方法です。Cognitoを使っている方の設計見直しのきっかけになれば幸いです。
対象システムの構成
対象となったシステムの構成を簡単に紹介します。フロントエンドはNuxt.js、バックエンドは一般的なAWSサーバーレス構成です。

認証はAWS Cognitoを利用し、フロントエンドからのAPI呼び出しは、CognitoのトークンをAPI Gatewayのオーソライザーで検証しています。この記事では、このうち「フロントエンドとCognitoの連携」におけるSDKの挙動に焦点を当てます。
背景
近年のサイバー攻撃の高度化と手口の多様化を踏まえ、今回の新規画面は「トークンの置き場所」を方針として先に決めてから実装することにしました。
私たちの社内サイトはIP制限などで一定の安全性を確保できる想定ですが、それだけではマルウェアなど端末起因のリスクは残ってしまいます。
そこで「残存データの持ち出し・流出」を減らす観点で確認したところ、特に意識せずに実装するとCognitoの認証トークンがブラウザのlocalStorageに保存される仕様になっていることに気づきました。

認証トークンがlocalStorageに保存されている原因
この挙動は、AWS Amplifyのデフォルト仕様によるものです。
SDKは、ログイン状態を維持し、ページリロード後もセッションが途切れないようにするため、デフォルトで認証情報をlocalStorageに保存する仕様になっていました。
以下のように当初想定していた初期化コードでは、Storageオプションを指定していなかったため、デフォルトのlocalStorageが使われていました。
// ★当初想定のコード this.poolData = { UserPoolId: config.cognito.UserPoolId, ClientId: config.cognito.ClientId // Storage未指定のためlocalStorageが使用される };
実装案の検討
認証トークンの置き場所は、利便性と「残存データの扱い」のバランスです。今回の対象は社内向けの管理者画面で、IP制限環境下・再ログイン許容という前提があります。この前提を踏まえ、デフォルトのlocalStorageに保存(案A)と、メモリのみ(案B)を並べて評価しました。UXでは「再起動やリロード後もそのまま使えるか」「タブをまたいだ作業が必要か」といった具体的な利用場面を、残存データでは「タブを閉じた後や端末の運用中にトークンが参照・複製されないか」という現実的なシナリオを軸に見ています。実装変更は「既存コードへの介入の大きさ」、運用影響は「導入後に現場で必要な手当て(再ログイン誘導、FAQ整備など)」の観点で判断しました。
| 観点 | 案A: localStorageに保存(デフォルト) | 案B: メモリのみ(ブラウザに残さない) |
|---|---|---|
| UX(再起動/リロード後の継続) | 高い(維持されやすい) | 低め(再ログインが必要) |
| マルチタブ | 可能(共有される) | 原則不可(同一タブ内のみ) |
| 残存データの持ち出し | 高い(後から吸い出されやすい) | 低い(タブを閉じれば消える) |
| ブラウザ同期/バックアップ複製 | あり得る | なし |
| 実装変更 | ほぼ不要(デフォルトのまま) | 必要(Storage差し替え+ストア運用) |
| 運用影響 | 小(期限切れ時のみ再ログイン) | 中(リロード/タブ複製で再ログイン) |
評価の進め方としては、まずサイトの利用シーンを棚卸し、利便性が落ちる場面とその頻度を確認しました。その上で、端末側で後からトークンが読み出され得るケース(ブラウザ同期、プロファイルコピー、共有端末での覗き見など)を洗い出し、どこまで“トークンが残らない”設計に寄せるべきかを関係者とすり合わせました。
サイト利用部門へのヒアリングの結果、「タブ複製や再読み込み時の利便性が一部下がっても、簡易な実装で残存トークンの持ち出しリスクを下げられるならそちらを優先したい」という方針で合意しました。これを受け、今回は案B(メモリのみ)を採用しています。
実装(メモリのみ)
ライブラリ側に「保存を完全に無効化する」設定はないため、保存先(Storage)を“何もしない実装”に差し替える方式で対応します。CognitoUserPoolはStorageを指定でき、渡した保存先にSDKがトークンを書き込みます。ここに「書き込み呼び出しを受けても何もしない」実装を渡します。
// ★最終的なコード // 何もしない保存先を定義 const memoryStorage = { setItem: (key, value) => {}, getItem: (key) => null, removeItem: (key) => {}, clear: () => {} }; // SDKに「保存先はこれ」と渡す this.poolData = { UserPoolId: config.cognito.UserPoolId, ClientId: config.cognito.ClientId, Storage: memoryStorage };
これで、SDKは「いつも通り保存しよう」とはしますが、渡された保存先が“何もしない箱”なので、ブラウザのストレージには一切残りません。トークンはサインイン時などにCognitoから受け取り、アプリのメモリで持つ、という運用に切り替わります。
まとめ
今回の件では以下の学びを得ました。
- ライブラリやSDKの初期設定は「一般解」に過ぎない。自分たちの要件に合わせて、カスタマイズが必要かどうか検討すべき
- 想定する脅威に対して現行仕様がどう振る舞うかを、方針として明文化しておく
(例: 「トークンの保存場所はブラウザに残さない/メモリのみ」など)
今回の案は、フロント側の最小変更で“残存トークンの持ち出し”に効く対策になっています。自システムの利用形態(社内/IP制限/再ログイン許容の可否)を踏まえ、トークンの置き場所を意識できていない方は、まず方針を明文化し可能な範囲から導入してみてはいかがでしょうか。
また、現状のセキュリティ対策方針と実現方法を明文化できていれば、将来の要件変更にも対応しやすくなります。本記事の対応が済んでいる方/今回の対策が不要だった方にとっても、自システムを見直すきっかけになれば幸いです。