Tech Learning Daily

2026-08-14 (Fri) — 第 29 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Security 🌿 基礎 ⏱ 約 8 分

JWT はなぜ、改ざんには気づけるのに失効はできないのか?

昨日もログイン API のレスポンスで受け取った JWT を、そのまま Authorization ヘッダーに乗せてリクエストを送ったはずだ。あの三つのドットで区切られた文字列は、サーバー側で一体何を検証していて、なぜ「ログアウト」ボタンを押した後もまだ使えてしまうことがあるのか、説明できるだろうか。

🎯 3 行まとめ
  • JWT の Header.Payload.Signature は base64url でエンコードされているだけで暗号化ではなく、Payload の中身は誰でも読める。署名が守っているのは改ざんの検知と発行者の証明であって、機密性ではない
  • 署名検証時にアルゴリズムをトークン任せにすると、RS256 用の公開鍵を HS256 の秘密鍵として悪用されるアルゴリズム混同攻撃を許してしまう。検証側は許可するアルゴリズムを固定して呼ぶ必要がある
  • JWT はサーバーが状態を持たずに検証できる代わりに exp まで失効させられない。即時ログアウトを実現するには、短命な access token とサーバー側で失効できる refresh token を組み合わせる必要がある

JWT の中身は誰でも読める — 署名は暗号化ではない

JWT(JSON Web Token)は ヘッダー.ペイロード.署名 の 3 つを . でつないだ文字列だ。それぞれの部分は JSON を base64url でエンコードしたものにすぎない。base64url は URL に含めても壊れないよう文字集合を調整しただけのエンコード方式であり、暗号化ではないので鍵を持たなくても誰でも元の JSON に戻せる。

Header にはどの署名アルゴリズムを使ったかという alg などのメタ情報、Payload には sub(ユーザー ID)や有効期限といったクレーム(主張したい情報)が入る。試しに手元のブラウザで自分のログイン後の JWT をコピーし、. で区切って先頭 2 つを base64url デコードしてみるとよい。ユーザー ID やロールがそのまま平文の JSON で読めるはずだ。ここに個人情報やパスワードのような機密情報を入れてはいけない理由はここにある。

最後の 署名(Signature)だけが特別で、Header と Payload の文字列に対して秘密鍵や共有シークレットで計算した値になっている。署名の役目は「この内容は発行者が作った通りで、途中で書き換えられていない」ことを検証者に証明することだ。1 文字でも Payload を書き換えれば署名の再計算結果が一致しなくなるので改ざんは検知できるが、Payload 自体を覗き見られることは防げない。機密性が要るなら別途 JWE(JSON Web Encryption)で暗号化するか、そもそも機密情報を JWT に載せない設計にする。

署名はどう検証されるのか — HS256 と RS256、そして混同されると危険な理由

署名アルゴリズムには大きく 2 系統ある。HS256 は HMAC-SHA256 による共通鍵方式で、発行者と検証者が同じシークレットを持つ。バックエンドが 1 つしかなく、そのサーバー自身が発行も検証もするなら実装がシンプルで済む。一方 RS256ES256 は RSA・楕円曲線による公開鍵方式で、秘密鍵を持つ発行者だけがトークンを作れるが、検証者は公開鍵さえあれば偽造される心配なく検証できる。認証サーバーが 1 つで、検証する側のマイクロサービスが複数あるような構成では、全サービスに秘密鍵を配らずに済む RS256 系が選ばれることが多い。

ここで見落としやすいのが、Header の alg フィールドをトークン側の自己申告のまま信用してはいけないという点だ。検証ライブラリの実装によっては、トークンの alg を読んでそのまま対応する検証方式を切り替えてしまうものがある。攻撃者が RS256 で発行されたトークンの algHS256 に書き換え、本来は公開されている RSA の公開鍵を HMAC の「共有シークレット」として使って署名を計算し直すと、検証側がそれを信じてしまう場合がある。公開鍵は名前の通り公開されている値なので、これは HS256 の安全性の前提(鍵が秘密であること)を丸ごと破っている。algnone(署名なし)を指定して署名欄を空にするだけで検証を素通りする、さらに雑な実装もある。

┌── JWT 検証で確認する順番 ──┐
│ ① alg を許可リストと照合      │
│   トークン内の alg は信用しない │
│ ② signature を鍵で再計算      │
│   HS256: 共有シークレット      │
│   RS256: 発行者の公開鍵        │
│ ③ exp/nbf/iss/aud を確認      │
└──────────────────────────┘

「署名されているのだから安全」というのは半分しか正しくない。安全なのは検証ロジックが正しく実装されている場合に限られる。防ぐ側の実装は、検証時に許可するアルゴリズムをコード側で ["RS256"] のように固定して渡し、トークンの Header に書かれた alg をそのまま検証方式の選択に使わないことが原則になる。

🍱 たとえるなら

JWT は透明な封筒に入った手紙に、送り主の印章(Signature)を押しただけのものに近い。封筒が透明なので中身(Payload)は誰でも読める。読めないようにしたいなら別に不透明な封筒(暗号化)が要る。印章の役目は「この手紙は本当にこの送り主が書いた通りの内容だ」と証明することで、文面を書き換えれば印章の模様も崩れて偽物だとバレる。ただし受け取った側が印章の種類(HS256 か RS256 か)を封筒に書かれたラベルの自己申告だけで信じてしまうと、偽物の印章を本物として受け入れてしまう危険がある。

標準クレームが担っている検証 — exp / iat / nbf / iss / aud

RFC 7519 は Payload に入れられる標準クレームをいくつか定義している。exp(expiration)はこの時刻以降トークンを受け付けてはいけない期限、iat(issued at)は発行時刻、nbf(not before)はこの時刻より前は無効という開始時刻を示す。iss(issuer)は発行者、aud(audience)はこのトークンがどのサービス向けかを示す識別子だ。検証側はこれらを署名の正しさとは別にアプリケーションロジックとして確認する必要があり、署名さえ正しければ自動的に検証されるわけではない。

exp を短く設定すると、万一トークンが漏洩しても悪用できる時間の窓が狭まる代わりに、クライアントは頻繁に新しいトークンを取り直す必要が出てくる。長く設定すれば取り直しの頻度は減るが、漏洩時の被害が長引く。aud を検証しないと、あるサービス向けに発行したトークンを別のサービスがそのまま受理してしまい、本来意図していない相手にもトークンが使い回されてしまう。

なぜ JWT は「発行したら取り消せない」のか — ステートレスの代償

セッション ID 方式の認証では、サーバーはセッションストアに問い合わせて有効性を確認するので、そのレコードを消せば即座に無効化できる。JWT はこの問い合わせを省略できることが利点で、検証者は署名とクレームだけを見ればよく、発行者にデータベースを問い合わせる必要がない。しかしこれは裏を返すと、発行者側が「このトークンはもう無効だ」と記録しても、検証者がその記録を見に行く仕組みを持たない限り、署名とクレームだけを見ている検証者には伝わらないということでもある。トークンは暗号的には exp の瞬間まで有効であり続ける。

「ログアウトすれば使えなくなる」と思いたくなるが、実際にはログアウト操作はクライアント側でトークンを破棄しているだけで、サーバー側の検証ロジックに変化はないことが多い。攻撃者が事前にトークンをコピーしていれば、ユーザー本人がログアウトした後もそのコピーは exp まで有効なままだ。

この問題への実務的な解決策は、access token 自体を数分〜十数分程度の短命にし、長期間有効な refresh token をサーバー側のデータベースで管理して、ログアウト時にはその refresh token だけを失効させるという二段構えだ。refresh token はステートフルに管理されるので取り消せる。access token は失効させられない代わりに寿命を短くすることで、漏洩や悪用の窓を実用上許容できる範囲に抑える。即時に全セッションを無効化したい場合は、ユーザーごとの「トークンバージョン」を持たせ、バージョンが古い access token を検証時に拒否するといった、部分的に状態を持ち込む設計を足すこともある。

💼 実務でどう出会うか

SPA やモバイルアプリがログイン後の API 呼び出しで Authorization: Bearer <JWT> を付ける場面、API Gateway や BFF がリクエストの入り口でトークンを検証する場面、マイクロサービス間で認証済みユーザーの情報を引き回すための内部トークンとしての利用など、いずれも JWT が実体になっていることが多い。OAuth 2.0 の access token や OpenID Connect の ID Token も、実装上は JWT 形式で発行されるのが一般的だ。ログイン状態がおかしいバグに遭遇したら、まず該当の JWT をデコードして expaud が期待通りかを確認すると切り分けが早い。

⌨️ 手を動かす(5 分)

JWT が暗号化ではなく「読める JSON + 署名」であることを、外部サービスを使わずローカルの python3openssl だけで確認する。以下はシークレット my-secret で署名済みのサンプル JWT だ。

JWT="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlRhcm8iLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTcwMDAwMzYwMH0._VnWt8iHVN9p8G--p81E84MWSCrPphNZYSnAlpoPFzM"

# 1. Header と Payload をデコード(暗号化されていないことの確認)
python3 -c "
import base64
h, p, s = '$JWT'.split('.')
def d(x):
    x += '=' * (-len(x) % 4)
    return base64.urlsafe_b64decode(x)
print(d(h).decode())
print(d(p).decode())
"

# 2. 同じシークレットで署名を再計算し、Signature 部分と一致するか確認
echo -n "${JWT%.*}" | openssl dgst -sha256 -hmac "my-secret" -binary \
  | base64 | tr '+/' '-_' | tr -d '='

1 では Header に {"alg":"HS256","typ":"JWT"}、Payload に {"sub":"1234567890",...} という平文の JSON がそのまま出力される。2 で計算した文字列は JWT 末尾の Signature 部分 _VnWt8iHVN9p8G--p81E84MWSCrPphNZYSnAlpoPFzM と一致するはずだ。これは、正しいシークレットさえ知っていれば誰でも「本物と同じ署名」を計算できる、つまり検証とは署名の再計算による一致確認にすぎないことを表している。

🙅 よくある誤解
  • JWT は暗号化されているから中身は読めない — base64url エンコードされているだけで、誰でもデコードして Payload の JSON を読める。機密情報を平文で入れてはいけない。
  • 署名されているのだから、サーバーは alg をそのまま信用してよい — alg をトークン任せにすると、RS256 用の公開鍵を HS256 の共有シークレットとして悪用されるアルゴリズム混同攻撃を許してしまう。検証側で許可するアルゴリズムを固定する必要がある。
  • ログアウトすればその JWT はもう使えなくなる — JWT はサーバー側の状態照会なしにステートレスで検証されるため、exp まで暗号的には有効であり続ける。即時失効させたいなら短命化とサーバー側で管理する refresh token の失効を組み合わせる必要がある。
📖 用語ミニ辞典
JWT(JSON Web Token)
Header・Payload・Signature を base64url エンコードして . で連結した、署名付き情報の受け渡しフォーマット。
署名(Signature)
Header と Payload に対して鍵で計算した値。改ざん検知と発行者の証明に使われ、暗号化(機密性の確保)とは目的が異なる。
アルゴリズム混同攻撃
検証側がトークンの alg をそのまま信用することで、RS256 の公開鍵を HS256 の秘密鍵として悪用されるなどして署名検証をすり抜けられる攻撃。
クレーム
JWT の Payload に含まれる主張情報。exp(有効期限)・iss(発行者)・aud(対象者)などの標準クレームが RFC 7519 で定義されている。
リフレッシュトークン
短命な access token を再発行するための長期間有効なトークン。サーバー側でステートフルに管理され、ログアウト時などに失効させられる。
🔗 もっと深く