「alg=none」攻撃 — 2026年になってもアプリが被害を受け続ける理由
JWT の仕様(RFC 7519)は、トークンのヘッダーにアルゴリズムフィールドを定義しています。有効な値には HS256、RS256、ES256、EdDSA — そして "none" が含まれます。"none" アルゴリズムは、トークンがまったく署名されていないことを意味します。当初の理由付けは「JWS の完全性が他の手段で既に検証されている場合に有用」というもの — つまり、別のレイヤーが完全性を保証するパイプラインを想定したものでした。実際には、その脚注が現代の認証で最も頻繁に引用される脆弱性クラスの一つを生み出しました。
古典的な攻撃はこうです。開発者が JWT ライブラリを組み込み、検証関数を jwt.verify(token, secret) のような形で呼び出します。ライブラリは、ヘッダーに { "alg": "none" } と書かれたトークンを受け取ると、アルゴリズムを見て検証すべき署名が存在しないと判断し、ペイロードを「検証済み」として返します。何らかのトークンを作成できる攻撃者 — たとえば正規に入手したトークンを改ざんするなど — は、alg を none に設定して署名を取り除くだけで、誰にでもなりすましてログインできます。この攻撃は多くの言語・多くのライブラリで生き残っているため、CVE リストには毎年新しい項目が追加され続けています: CVE-2015-9235(jsonwebtoken)、CVE-2018-1000531(inversoft)、CVE-2019-7644(auth0/java-jwt)、CVE-2022-23529(再び jsonwebtoken)、そして 2024 年まで続いています。
2 つ目の変種は「アルゴリズム混同」攻撃です。RS256(非対称)は公開鍵を使って署名を検証します。HS256(対称)は共有シークレットを使って検証します。サーバーが RS256 の公開鍵を保存していて、検証関数が「key」を汎用パラメータとして受け取る場合、攻撃者はヘッダーを alg: HS256 に切り替え、その公開鍵を HMAC のシークレットであるかのように使ってトークンに署名します。するとサーバーは同じ鍵で「検証」を行って成功し、偽造トークンを受け入れてしまいます。CVE-2016-10555、CVE-2019-20933 をはじめ、その後の多くの問題がまさにこのパターンです。
防御は単純ですが、忘れやすいものです。トークン内の alg フィールドを決して信頼しないでください。検証側は期待するアルゴリズムをハードコード — 少なくとも厳格な許可リストに — し、それ以外(特に "none")はすべて拒否しなければなりません。正しい呼び出しは jwt.verify(token, key, { algorithms: ['RS256'] }) のような形で、このオプションが実際に渡されていることをコード上で確認する必要があります。2020 年頃以降、多くのライブラリは安全な許可リストをデフォルトにしていますが、このオプションなしで検証器を構築するレガシーコードは世の中にまだ大量に残っています。
alg のほかにも、検証側は exp(期限切れでないこと)、nbf(既に有効であること)、iss(発行者が一致すること)、aud(対象者が自分であること)、そして必要とする nonce や jti をすべてチェックしなければなりません。JWT は署名された JSON にすぎません。暗号署名はバイト列が変更されていないことを証明するだけで、そのバイト列があなたのアプリケーションにとって正しい意味を持つことまでは証明しません。
// VULNERABLE — accepts alg=none and algorithm confusion
const payload = jwt.verify(token, key);
// HARDENED — explicit allowlist + claim checks
const payload = jwt.verify(token, key, {
algorithms: ['RS256'], // never trust header alg
issuer: 'https://api.example.com',
audience: 'https://app.example.com',
clockTolerance: 30, // 30s skew, not minutes
});
// alg=none token an attacker would craft
header = base64url('{"alg":"none","typ":"JWT"}')
payload = base64url('{"sub":"admin","exp":9999999999}')
signature = '' // empty
token = `${header}.${payload}.` // trailing dot, no sig
// Algorithm confusion: signing with the RS256 public key as HMAC key
const pub = fs.readFileSync('public.pem');
const forged = jwt.sign({ sub: 'admin' }, pub, { algorithm: 'HS256' });
// If the verifier doesn't pin algorithms, this passes.正しいリフレッシュトークン運用 — ローテーション、再利用検知、スライディングセッション
アクセストークンは短命(5〜15 分)で、bearer 型であり、データベース参照なしで検証できます。リフレッシュトークンは長命(数日〜数週間)で、新しいアクセストークンと交換するために使われ、アクセストークンとは異なり、要求に応じて即座に失効できなければなりません。JWT ベースのシステムの多くが静かに失敗するのは、このリフレッシュフローの設計です。
OAuth 2.1 ドラフトと IETF の「OAuth 2.0 Security Best Current Practice」(RFC 9700、2024 年)は、いずれもリフレッシュトークンのローテーションを必須としています。クライアントがリフレッシュトークン R を新しいアクセストークンと交換するたびに、サーバーは新しいリフレッシュトークン R' も発行し、R を無効化します。この単回使用という性質が決定的に重要です。これがなければ、盗まれたリフレッシュトークンは攻撃者に無期限のアクセスを与えます。ローテーションがあれば、R を盗んだ攻撃者はそれを一度使えるだけで、正規ユーザーのクライアントがリフレッシュを試みた瞬間に、サーバーは R の 2 回目の使用を検知し、何かがおかしいと判断して、セッションファミリー全体を失効させることができます。
この「再利用検知」こそがローテーションの最大の強みです。実装は次のとおりです。発行する各リフレッシュトークンと一緒に、リフレッシュトークンのファミリー識別子(ログインセッションごとの UUID)を保存します。リフレッシュを受け付けるたびに、古いトークンを使用済みとしてマークします。既に使用済みとマークされたトークンでリフレッシュ要求が来た場合、それは (a) 正規クライアントがネットワークの不具合で再送しているか、(b) 誰かがトークンのいずれかを盗んだか、のどちらかです。どちらであっても安全な対応は同じです。そのファミリーのすべてのリフレッシュトークンを無効化し、新規ログインを強制し、セキュリティイベントを記録します。Auth0、Stripe、Supabase をはじめ、事実上すべての主要な認証プロバイダーがまさにこのパターンを実装しています。
スライディング有効期限が 2 つ目の重要なアイデアです。「リフレッシュトークンは発行から 30 日で失効」という固定の期限ではなく、最大アイドル期間(「最後の使用から 14 日以内に使用しなければならない」)と絶対有効期間(「いずれにせよ初回発行から 90 日で失効」)の両方を設定します。これにより、何年も生き続けるリフレッシュトークンというセキュリティ上の悪夢を抱えることなく、「アプリを使うのをやめるまでログインしたまま」という UX を実現できます。
保存に関する実務上の注意: リフレッシュトークンは JWT にすべきではありません。JWT はステートレスで自己完結型であり、即座に失効させる必要があるものにはまったく不向きな形です。リフレッシュトークンは、サーバー側で不透明なランダム文字列(Base64URL エンコードした 32 バイト以上の乱数)としてデータベースにキー付きで保存し、ファミリー ID、ユーザー ID、デバイスフィンガープリント、発行時刻、最終使用時刻、「使用済み」フラグを一緒に持たせます。アクセストークンは JWT のままで構いません。十分に短命なので失効の重要性が下がり、即時失効といっても最大でアクセストークンの寿命 1 回分待つだけで済むからです。アクセストークンにも本当に即時失効が必要なら、JWT を完全にやめて、リクエストごとにサーバー参照を行う不透明なセッション ID を使うべきです。それが Stripe、GitHub、そして大半のコンシューマー向けアプリが実際に行っていることです。
// Refresh endpoint with rotation + reuse detection
async function refresh(refreshToken) {
const row = await db.refreshTokens.findOne({ token: refreshToken });
if (!row) throw new Unauthorized();
if (row.used) {
// REPLAY: revoke the entire family
await db.refreshTokens.updateMany(
{ familyId: row.familyId },
{ revoked: true }
);
auditLog('refresh-token-replay', { userId: row.userId });
throw new Unauthorized('session compromised');
}
// Mark current as used + idle window check
if (Date.now() - row.lastUsedAt > 14 * DAY) throw new Unauthorized();
if (Date.now() - row.firstIssuedAt > 90 * DAY) throw new Unauthorized();
await db.refreshTokens.update({ id: row.id }, { used: true });
// Issue rotated pair
const newAccess = signJWT({ sub: row.userId }, { expiresIn: '15m' });
const newRefresh = base64url(crypto.randomBytes(32));
await db.refreshTokens.insert({
token: newRefresh,
familyId: row.familyId, // same family, new member
userId: row.userId,
used: false,
firstIssuedAt: row.firstIssuedAt, // PRESERVE absolute lifetime
lastUsedAt: Date.now(),
});
return { accessToken: newAccess, refreshToken: newRefresh };
}JWE と JWS — 署名だけでなく暗号化が必要になるとき
世の中で見かける「JWT」のほとんどは JWS — JSON Web Signature です。ペイロードは Base64URL エンコードされているだけで暗号化されていないため、トークンを持っている人は誰でも、真ん中のセグメントを base64 デコードすればすべてのクレームを読めます。クレームが機密でないもの(「ユーザー 12345 がログイン中、X に失効」)であれば問題ありませんが、メールアドレス、ロール名、ビジネス上重要な内部ユーザー ID、顧客のプラン階層など、競合他社や規制当局の監査人に読まれたくない情報が含まれる場合は誤った選択です。
JWE — JSON Web Encryption — は、JWS と同じアイデアを包みつつ、ペイロードも暗号化します。JWE トークンは 3 セグメントではなく 5 セグメント(ヘッダー、暗号化された鍵、IV、暗号文、認証タグ)で構成されます。ペイロードを読むには復号鍵が必要です。機密データを含むトークンを第三者(Webhook の受信者、リダイレクト URL、完全には信頼できないモバイルクライアント)経由で送る必要があるなら、JWE が正しいツールです。
判断ルールは明快です。「このトークンの保持者はクレームを読む必要があるか?」と問います。イエスなら JWS — 保持者は自分のバックエンドまたは自分のクライアントであり、署名が完全性の保証になります。ノーなら — トークンが中身を知るべきでないコードを通過し、最後に自分のサービスだけが復号するなら — JWE です。よく使われる組み合わせパターンが「ネスト JWT」です。まず JWS で署名し、それを JWE の平文としてラップします。受信者は暗号化(自分だけが読める)と署名(発行者を検証できる)の両方を得られます。
2026 年のアルゴリズム選択: JWS では、新規システムには EdDSA(Ed25519)を推奨します。最速で、署名が最も小さく、カーブ混同の落とし穴もありません。ES256(ECDSA P-256)は幅広くサポートされたフォールバックです。RSA を要求する PKI インフラに縛られているなら RS256 でも構いませんが、鍵は巨大で署名は低速です。JWE では、対称構成なら鍵ラップ付きの AES-GCM を直接使い(alg=A256KW + enc=A256GCM)、非対称の場合は ECDH-ES と AES-GCM を使います。RSA-OAEP は必須でない限り避けてください。低速で、鍵サイズも過酷です。
最後の注意: 暗号化は「そもそも機密データをトークンに入れない」ことの代替にはなりません。トークンはログに残り、キャッシュされ、コピー&ペーストされ、スクリーンショットに写ります。「クレームは検証側が必要とする最小限にすべき」という原則は、JWS と同様に JWE にもそのまま適用されます。漏れたら危険すぎるクレームの最も安全な置き場所はデータベースであり、JWT には不透明なポインタだけを持たせるべきです。
// JWS — payload is readable, just signed
header.payload.signature
^^^^^^^.^^^^^^^.^^^^^^^^^
3 segments, base64url decode the middle to read claims.
// JWE — payload is encrypted (5 segments)
header.encryptedKey.iv.ciphertext.tag
// Node 'jose' library
import { SignJWT, jwtVerify, EncryptJWT, jwtDecrypt } from 'jose';
// JWS sign with EdDSA (recommended new default)
const jws = await new SignJWT({ sub: '123' })
.setProtectedHeader({ alg: 'EdDSA' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(privateKey);
// JWE encrypt with AES-GCM
const jwe = await new EncryptJWT({ ssn: '123-45-6789' })
.setProtectedHeader({ alg: 'A256KW', enc: 'A256GCM' })
.setExpirationTime('5m')
.encrypt(symmetricKey);
// Nested: sign then encrypt
const inner = await new SignJWT({ sub: '123', email: '[email protected]' })
.setProtectedHeader({ alg: 'EdDSA' }).sign(signingKey);
const outer = await new EncryptJWT({ jwt: inner })
.setProtectedHeader({ alg: 'A256KW', enc: 'A256GCM' }).encrypt(encKey);