ログイン状態を保つ方法は、大きく2つあります。サーバー側に記録を残すセッションと、利用者側にトークンを持たせるJWTです。違いは「状態をどこに置くか」の一点で、そこから性質の差が全部出てきます。
今回も手を動かしました。署名を確かめない実装を書いて、admin を true に書き換えたトークンを投げたらどうなるかを試しています。結果を先に言うと、そのまま管理者として受理されました。そこから、両者の選び方までを見ていきます。
違いは1つだけで、ログイン状態をどこに置くかです。セッションはサーバー側に、JWTは利用者側に置きます。ここから使い勝手の差が全部出てきます。
セッションとJWTを比べる前に、なぜこの仕組みが要るのかを押さえておきます。Webの通信は、1回ごとに独立しているという前提で作られているからです。
つまりサーバーから見ると、さっきログインした人と、いま画面を開いた人が同じかどうかは分かりません。そこで「あなたはさっきログインした人です」と示す仕組みが要ります。それがセッションであり、JWTです。
セッションでは、ログインした時点でサーバー側に記録を作ります。利用者が持つのは、その記録を指す番号(セッションID)だけです。次に来たときは、その番号でサーバーの記録を引きます。
JWTでは、サーバーに記録を残しません。代わりに「この人はaliceで、管理者ではない」といった内容を書いたトークンを渡し、利用者に持ってもらいます。次に来たときは、そのトークンの中身を読みます。
ここで当然の疑問が出ます。持ち主が書き換えたらどうなるのか、です。それを防ぐのが署名で、この記事の後半はまるごとその話になります。
| セッション | JWT | |
|---|---|---|
| 状態の置き場所 | サーバー側 | 利用者側 |
| 利用者が持つもの | 記録を指す番号だけ | 中身の入ったトークン |
| 毎回の確認方法 | サーバーの記録を引く | 署名を検証する |
| 中身を読めるか | 読めない(番号のみ) | 誰でも読める |
| 即座に無効化 | できる(記録を消す) | できない(期限まで有効) |
| サーバーの負荷 | 記録の保管が要る | 保管は不要 |
JWTの方が新しい技術なので優れている、と受け取られることがあります。ただ両者は用途が違うだけです。複数のサービスにまたがる構成ではJWTが向き、単一のWebアプリならセッションの方が簡単に済みます。この記事の最後で、選び方をもう一度整理します。
HTTP is a stateless protocol ( RFC2616 section 5), where each request and response pair is independent of other web interactions原文OWASP Cheat Sheet Series「Session Management」 この内容の有効期限2027-02-17
署名の検証を省いた実装を書いて、権限を書き換えたトークンを投げました。そのまま管理者として受理されます。期限切れのトークンも通りました。
セッションとJWTの安全性の差は、実装の手数から生まれます。手元で試したので、その結果を見てください。
用意したのは、JWTを受け取る関数を2つです。片方は中身を読むだけ、もう片方は署名とアルゴリズムと期限を確かめます。下のコードは読み飛ばして大丈夫です。
// 危険: 中身を読むだけ。署名も期限も見ていない
function verifyUnsafe(token) {
const [, body] = token.split('.');
return JSON.parse(Buffer.from(body, 'base64url').toString());
}
// 安全: アルゴリズムを固定し、署名と期限の両方を確かめる
function verifySafe(token) {
const [h, b, sig] = token.split('.');
const header = JSON.parse(Buffer.from(h, 'base64url').toString());
if (header.alg !== 'HS256') return { error: 'alg が想定と違う' };
const expected = crypto.createHmac('sha256', SECRET).update(`${h}.${b}`).digest('base64url');
if (sig !== expected) return { error: '署名が一致しない' };
const payload = JSON.parse(Buffer.from(b, 'base64url').toString());
if (payload.exp * 1000 < Date.now()) return { error: '有効期限切れ' };
return payload;
}
--- 正規のトークン --- 検証なしの実装: 受理 user=alice admin=false 検証ありの実装: 受理 user=alice admin=false --- 攻撃1: 署名を外し、admin を true に書き換えた(alg:none)--- 検証なしの実装: 受理 user=alice admin=true 検証ありの実装: 拒否(alg が想定と違う) --- 攻撃2: 期限切れの正規トークンを再利用 --- 検証なしの実装: 受理 user=alice admin=false 検証ありの実装: 拒否(有効期限切れ)
通りました。admin=true です。署名は付けていません。トークンの中身を書き換えて、署名の欄を空にしただけです。
JWTはヘッダに「どの方式で署名したか」を書きます。そこに none(署名なし)と書けてしまうのが問題です。RFCは、攻撃者がこの値を none に変えると、一部のライブラリはそれを信じて署名を確かめずに通してしまう、と明記しています。
つまりヘッダの値を信用してはいけないということです。受け取る側が「うちはHS256しか受け付けない」と決めておけば、この攻撃は成立しません。上の安全な実装がやっているのがそれです。
もう1つの結果も見てください。期限が切れた正規のトークンも、検証なしの実装は受理しています。署名は正しいので、期限を見ていなければ何年前のトークンでも通ります。
JWTの検証は3つそろって初めて成立する。1つでも欠けると、その分だけ穴が開く。
入力を信用しない、という点ではSQLインジェクションの記事と同じ構図です。あちらは入力がSQLの命令として読まれる問題で、こちらは受け取ったトークンの自己申告を信じてしまう問題です。
The algorithm can be changed to "none" by an attacker, and some libraries would trust this value and "validate" the JWT without checking any signature.原文RFC 8725「JSON Web Token Best Current Practices」 この内容の有効期限2027-02-17
判断の分かれ目は、すぐに失効させたい場面があるかどうかです。あるならセッション。無いか、複数サービスにまたがるならJWTが向きます。
セッションとJWTのどちらを選ぶかは、好みではなく構成で決まります。まず、いちばん効く判断基準から見ます。
退会した直後、パスワードを変えた直後、不正が疑われたとき。こうした場面でいますぐログイン状態を切りたいなら、セッションが向きます。サーバー側の記録を消すだけで済むからです。
JWTではこれができません。発行したトークンは期限が来るまで有効です。対処として失効した一覧をサーバーに持つ方法もありますが、それは「サーバーに記録を持たない」というJWTの利点を手放すことになります。
どちらを使う場合でも、有効期間は短いほど安全です。OWASPも、期間が短いほど攻撃者が使える時間が減ると述べています。ただし短くすると再ログインが増えるので、使い勝手との兼ね合いになります。
失効の必要性が最初の分かれ道。次にサービスが1つかどうかで決まる。
個人開発で作るWebアプリの多くは、サービスが1つで、退会や不正時にすぐ切りたい場面があります。その条件ならセッションで足ります。JWTを選ぶ理由が特に無いなら、実装が簡単な方を選んで構いません。
短い期限のJWTと、失効できる長期のトークンを組み合わせる構成もよく使われます。普段の通信はJWTで軽く済ませ、期限が切れたら長期トークンで作り直す形です。ただし仕組みが増えるぶん、確認すべき箇所も増えます。最初から選ぶ構成ではないと編集部は考えています。
The shorter the session interval is, the lesser the time an attacker has to use the valid session ID.原文OWASP Cheat Sheet Series「Session Management」 この内容の有効期限2027-02-17
同じ課題を持つ会社にとって、動いている設定は「作る時間」を買えるということです。ServiceDockは自作のワークフローやテンプレートを出品できるマーケットプレイスです。手数料や出品の流れは出品者向けページにまとまっています。
出品の仕組みを見る