「ブラウザを閉じてもログイン状態を維持し、端末ごとにログアウトもさせたい」という要件が来たとする。JWTやセッションIDを、さてどこにしまっておくか。localStorage、sessionStorage、Cookieにはそれぞれ長所と短所がある。なお、同じブラウザで情報を保持することと、別の端末でもログインすることは別で、保存先だけでは端末間の認証は実現しない。

そこで、代表的な4つの選択肢を比べながら、なぜ最終的に1つの方法に落ち着くのかを考えてみた。セッション情報は、いわばホテルの部屋の鍵である。どこに置くのか、誰が無効にできるのか。その2点で眺めると、話は存外すっきりする。

4つの選択肢

まず、localStorage に JWT を置く方法である。ブラウザを閉じても値は残るので、永続性の点では申し分ない。しかし JavaScript から中身が丸見えなので、XSS(クロスサイトスクリプティング)にはめっぽう弱い。

悪意のあるスクリプトが一度でも実行されれば、その瞬間にトークンは持ち去られる。鍵を廊下の植木鉢の下に隠しておくようなものだ。セキュリティを気にするなら、真っ先に候補から外れる代物である。

次に、HttpOnly CookieにJWTを入れる方法がある。Max-Age や Expires で有効期限を設定すれば、ブラウザを閉じた後も保持できる。HttpOnlyならJavaScriptから値を直接読み出せない。ただし、XSSによる認証済み操作の実行まで防げるわけではない。Cookieの属性は、それぞれ守る対象が違う。

問題は失効の運用だ。署名と期限だけでJWTを検証する設計では、特定の端末だけを即座にログアウトさせにくい。フロントの台帳に載っていない合鍵を、客が持ち歩いているようなものだ。失効リストやセッション状態を照合すれば制御できるが、その分サーバー側の状態と管理が必要になる。

3つ目が、HttpOnly CookieにセッションIDを入れる方法だ。永続化にはCookieの有効期限を設定し、Secure属性でHTTPS通信に限定する。こちらも値の読み取りを防ぐ仕組みと、XSSそのものへの対策は分けて考える必要がある。

そして殊に優れているのが、運用の柔軟性である。セッションの実体はサーバー側の Redis や DB にあるので、サーバーの判断でいつでも無効化できる。フロントで磁気を消せるカードキーと同じだ。ログイン中のデバイス一覧を出すのも、パスワード変更時にすべてのセッションを破棄するのも造作ない。

最後に、sessionStorageへJWTを置く方法もある。基本的にタブを閉じると消えるので、ブラウザを閉じた後もログインを保ちたい今回の用途には合わない。JavaScriptから読める点では、localStorageと同じ注意が必要だ。

並べてみると次のようになる。

保存先ブラウザ終了後の保持JavaScriptによる値の読み取りサーバー側の失効制御
localStorage + JWT可能可能失効状態の照合などが必要
HttpOnly Cookie + JWT有効期限の設定による不可失効状態の照合などが必要
HttpOnly Cookie + セッションID有効期限の設定による不可保存したセッションを無効化
sessionStorage + JWT原則タブ終了まで可能失効状態の照合などが必要

セッション ID が選ばれる理由

僕は、ログアウトや端末管理を重視する一般的なWebアプリなら、HttpOnly Cookieとサーバー管理のセッションIDを選びたい。保存先の安全性と、発行後の制御を一緒に考えやすいからだ。理由を3つに分ける。

1つ目は、HttpOnlyでスクリプトからの直接の読み取りを防げること。2つ目は、サーバー側のセッションを無効にすれば、次の認証判定からアクセスを止められることだ。JWTでも失効管理はできるが、この構成なら失効をセッションの管理にまとめられる。

3つ目はデバイス管理になる。ユーザーが複数のデバイスでログインしていても、それぞれのセッションを個別に一覧・削除できる。サーバー側で状態を持っているからこその強みだ。

「スケーラビリティのためにJWTをlocalStorageへ」と決める前に、強制ログアウトの要件も考えたい。失効の仕組みを持たなければ、発行済みトークンの期限を待つことになる。あとでフロント係に合鍵の回収を頼んでも、台帳がなければ困ってしまう。

以上、保存と失効の両面から4つの方法を比べた。僕がこの用途で重視するのは、値を読み取られにくくすることと、発行後にもアクセスを止められることだ。Cookieとサーバー側セッションは、その2つを扱いやすくする。鍵は置き場所だけでなく、止め方まで決めて選びたい。