JWTとは何か、なぜこれほど普及したのか
APIを開発していると、ほぼ確実に出会うのがJWT(JSON Web Token)です。モバイルアプリのバックエンド、マイクロサービス間の認証、シングルサインオン(SSO)など、さまざまな場面で活用されています。それほど普及した理由は、「ステートレスな認証」を実現できるシンプルな仕組みにあります。
従来のセッションベース認証では、ログイン情報をサーバー側のセッションストアに保存しておき、クライアントはセッションIDだけを持ちます。サーバーがリクエストを受け取るたびにストアを参照して認証を行うため、スケールアウトしたときにセッション共有の仕組みが必要になります。JWTはこの問題を解決します。認証情報をトークン自体に含め、サーバーは署名を検証するだけで認証が完結するため、外部ストアが不要になります。
ただし、便利な技術には必ず落とし穴があります。JWTもその例に漏れず、実装を誤ると深刻なセキュリティ脆弱性を生む可能性があります。本記事では仕組みから実装上の注意点まで、実務で役立つ知識を整理します。
JWTの構造を理解する
JWTは3つのパーツをBase64URLエンコードしてピリオドで連結した文字列です。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
ヘッダー(Header)
アルゴリズムとトークンタイプを指定します。
{
"alg": "HS256",
"typ": "JWT"
}
ペイロード(Payload)
クレーム(claim)と呼ばれる情報のセットです。標準クレームとカスタムクレームを組み合わせて使います。
{
"sub": "user_12345",
"name": "田中 太郎",
"role": "admin",
"iat": 1700000000,
"exp": 1700003600
}
iat(issued at)は発行時刻、exp(expiration)は有効期限をUNIXタイムスタンプで表します。これらは標準クレームと呼ばれ、特別な意味を持ちます。
署名(Signature)
ヘッダーとペイロードを秘密鍵(または秘密鍵ペア)で署名したものです。これにより、トークンが改ざんされていないことを検証できます。
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
重要な認識として、ペイロードの内容は誰でも読めます。 Base64URLは暗号化ではなくエンコードであるため、デコードすれば中身が見えます。パスワードや個人情報をペイロードに含めてはいけません。
主要なアルゴリズムの比較
JWTで使用できる署名アルゴリズムはいくつかあり、それぞれ特性が異なります。
| アルゴリズム | 種別 | 鍵の構成 | 主な用途 |
|---|---|---|---|
| HS256 | 対称鍵 | 共通秘密鍵 | 単一サービス内の認証 |
| RS256 | 非対称鍵 | 公開鍵・秘密鍵ペア | マイクロサービス、外部連携 |
| ES256 | 非対称鍵(楕円曲線) | 公開鍵・秘密鍵ペア | モバイル・IoTなど軽量が必要な場面 |
HS256は設定がシンプルで処理も速い反面、署名と検証に同じ秘密鍵を使うため、複数サービスで秘密鍵を共有する必要があります。秘密鍵が漏洩すると攻撃者が任意のトークンを偽造できます。
RS256は認証サーバー(Authサーバー)だけが秘密鍵を持ち、各サービスは公開鍵だけを持てば検証できます。秘密鍵を一箇所にしか置かないため、マイクロサービス構成では RS256 が推奨されることが多いです。
よくある実装ミスとその危険性
JWTに関連する脆弱性の多くは、ライブラリの使い方の誤りから生まれます。代表的なものを見ていきましょう。
「alg: none」攻撃
古いJWTライブラリには、アルゴリズムを none に指定することで署名検証をスキップできてしまうバグが存在しました。攻撃者はヘッダーを {"alg": "none"} に書き換えたトークンを送り付けて、署名なしで任意のペイロードを受け入れさせることができます。
現代のライブラリは対策済みですが、検証時に「受け入れるアルゴリズムを明示的に指定する」実装を習慣にしましょう。
// NG: アルゴリズムを検証側に委ねてしまっている
jwt.verify(token, secret);
// OK: 許可するアルゴリズムを明示的に指定する
jwt.verify(token, secret, { algorithms: ['HS256'] });
有効期限(exp)の未設定
exp クレームを設定しないトークンは永遠に有効です。万が一トークンが漏洩しても無効化できません。実装時は必ず有効期限を設定し、短め(APIトークンなら15〜60分程度)にしておきましょう。
ペイロードへの機密情報の格納
先述のとおり、ペイロードはデコードするだけで誰でも読めます。password、クレジットカード番号、マイナンバーなど、機密性の高い情報を格納してはいけません。
弱い秘密鍵の使用
HS256で使用する秘密鍵が短い文字列や辞書単語だと、オフラインのブルートフォース攻撃で解析される恐れがあります。秘密鍵は256ビット(32バイト)以上のランダムなバイト列を使用し、環境変数や秘密管理サービス(AWS Secrets ManagerやHashiCorp Vaultなど)で安全に管理しましょう。
リフレッシュトークンとの組み合わせ
JWTはステートレスであるがゆえに、発行済みトークンの無効化が難しいという課題があります。ユーザーがパスワードを変更したり、不正利用を検知してトークンを失効させたりしたい場面では、対策が必要です。
一般的な解決策が「アクセストークン + リフレッシュトークン」の2トークン構成です。
[アクセストークン]
- 有効期限: 短い(15分〜1時間)
- 用途: APIリクエストごとに送信する認証トークン
- 保存場所: メモリ(JavaScriptの変数など)が理想
[リフレッシュトークン]
- 有効期限: 長い(数日〜数週間)
- 用途: アクセストークンの再発行に使う
- 保存場所: HttpOnly Cookie(JSからアクセスできない場所)
アクセストークンの有効期限が切れたら、リフレッシュトークンを使って新しいアクセストークンを取得します。リフレッシュトークンはサーバー側でデータベースに保存し、失効させたいときは削除または無効フラグを立てることで制御できます。
トークンの保存場所に注意する
フロントエンドでJWTをどこに保存するかは、セキュリティ上の重要な設計判断です。
| 保存場所 | XSSリスク | CSRF リスク | 推奨度 |
|---|---|---|---|
| localStorage | 高(JS から読める) | 低 | 非推奨 |
| sessionStorage | 高(JS から読める) | 低 | 非推奨 |
| HttpOnly Cookie | 低(JS から読めない) | 中(SameSite で軽減) | 推奨 |
| メモリ(JS変数) | 低 | 低 | 推奨(ページ遷移で消える点に注意) |
localStorage はXSS攻撃でJavaScriptが実行されると、トークンが盗まれるリスクがあります。セキュリティを重視するなら HttpOnly 属性付きのCookieに保存し、SameSite=Strict または SameSite=Lax でCSRF対策を組み合わせるのが現時点でのベストプラクティスです。
まとめ
JWTは正しく実装すれば非常に強力な認証手段ですが、誤ると致命的な脆弱性になります。重要なポイントをまとめると次のとおりです。まず、ペイロードには機密情報を含めないこと。次に、有効期限(exp)は必ず設定し、短くすること。アルゴリズムは検証時に明示的に指定すること。秘密鍵は十分な長さのランダムなバイト列を使い、安全に管理すること。そしてリフレッシュトークンと組み合わせて、トークンの無効化に対応できる設計にすることです。
JWTの仕組みを表面だけなぞって使うのではなく、「なぜこの設計になっているのか」を理解することで、実装ミスを防ぎ、セキュアなシステムを作り上げることができます。