JSON Web Tokenは妥当なフォーマットですが、鋭いふちが数多くあります。問題になるのはトークンそのものであることはまれです。バグは、どう検証するか、どこに保存するか、どれだけ有効か、ユーザーがログアウトしたとき何が起きるか、という点に潜みます。どの誤りも結果は同じです。持つべきでない人がトークンを持ち、サーバーがそれを受け入れてしまうのです。
最もよく現れる2つの弱点は、暗号署名の不適切な検証であるCWE-347と、セッション期限切れの不足であるCWE-613です。以下の例では、Node.js、エッジランタイム、ブラウザで動く広く使われているJavaScript向けJWTライブラリのjoseを使います。
デコードは検証ではない
CWE-347の最も直接的な形は、署名を確認せずにトークンからクレームを読むことです。どのJWTライブラリにもデバッグ用のdecode関数があり、それが認証ミドルウェアに現れる頻度は本来あるべきより高いのです。デコードしただけのトークンは、誰でも書けるbase64にすぎません。JavaScriptのコードベースでは、このパターンはトークンをドットで分割して真ん中をJSON.parseする小さなヘルパーに潜んでいることがよくあります。テストトークンは有効なのですべてのテストで動作し、本番ではあらゆる偽造トークンを受け入れます。
import { decodeJwt, jwtVerify } from 'jose';
// 脆弱:subを任意のユーザーIDにしたトークンを誰でも発行できる
const claims = decodeJwt(token);
const userId = claims.sub;
// 正しい例:署名、アルゴリズム、クレームを先に検証する
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] });
const verifiedUserId = payload.sub;alg none とアルゴリズム混同
JWTのヘッダーはどのアルゴリズムで署名されたかを示しますが、そのヘッダーを制御しているのは攻撃者です。これを信頼することから、2つの古典的な攻撃が生まれます。1つはalgをnoneにするもので、署名のないトークンを一部の古いライブラリが有効として受け入れました。もう1つはアルゴリズム混同です。RS256を期待しながらヘッダーにアルゴリズムを選ばせるサーバーには、サーバーの公開鍵をHMACのシークレットとして使って署名したHS256のトークンを渡せます。公開鍵は公開されているので、攻撃者は何でも署名できます。
最近のライブラリはどちらにも対策しており、joseはjwtVerifyで署名なしトークンを拒否し、鍵の種類がアルゴリズムと一致するかも確認します。しかし既定値だけに頼らないでください。検証呼び出しのたびにアルゴリズムのリストを明示的に固定し、将来のリファクタリングやライブラリの入れ替えで静かに広がらないようにします。鍵の移行中などで複数のアルゴリズムを受け入れる場合は、両方を明示的に列挙し、それぞれに別の鍵を使ってください。鍵をローテーションするときはヘッダーにkidを入れ、自前のリストからkidで鍵を選びます。トークンのヘッダーに書かれたURLから選んではいけません。
弱い署名シークレット
HS256のトークンは共有シークレットで署名されます。シークレットが短かったり、「secret」やアプリ名、チュートリアルからコピーした値のように推測できたりすると、有効なトークンを1つ持つ攻撃者は市販のツールでオフラインの総当たりができ、その後は任意のユーザーのトークンに署名できます。オフラインの推測にレート制限はありません。
- HS256には最低32バイトのランダム値を使い、openssl rand -base64 32 のような方法で生成します。
- シークレットは環境変数かシークレットマネージャーから読み込み、存在しないか短い場合は起動時に失敗させます。
- 決してコミットしないこと。過去にコミットされたことがあるなら、ローテーションしてください。
- 複数のサービスがトークンを検証する必要がある一方で発行するのは1つだけなら、RS256やEdDSAのような非対称アルゴリズムを使い、検証側は公開鍵だけを持つようにします。
exp、aud、issを検証する
署名が有効であることが証明するのは、誰がトークンを発行したかだけです。それが自分宛てなのか、今も有効なのかを決めるのはクレームです。有効期限のないトークンは永久に有効です。モバイルAPI向けに発行されたトークンが、同じIDプロバイダーを信頼しているというだけで管理サービスに受け入れられてはいけません。audとissはそのためにあります。
import { SignJWT, jwtVerify } from 'jose';
const rawSecret = process.env.JWT_SECRET;
if (!rawSecret || rawSecret.length < 32) {
throw new Error('JWT_SECRET must be set and at least 32 characters');
}
const secret = new TextEncoder().encode(rawSecret);
const ISSUER = 'https://api.example.com';
const AUDIENCE = 'https://app.example.com';
export function signAccessToken(userId: string, tokenVersion: number) {
return new SignJWT({ tv: tokenVersion })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(userId)
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setIssuedAt()
.setExpirationTime('10m')
.sign(secret);
}
export async function verifyAccessToken(token: string) {
const { payload } = await jwtVerify(token, secret, {
algorithms: ['HS256'],
issuer: ISSUER,
audience: AUDIENCE,
requiredClaims: ['exp', 'sub'],
});
return payload;
}joseはexpが存在すれば必ず確認しますが、expのないトークンはそのままでは通ってしまいます。requiredClaimsオプションがこの隙を塞ぎます。外部のIDプロバイダー発行のトークンについては、createRemoteJWKSetで公開された鍵セットに対して検証し、それでもアルゴリズム、発行者、対象者を明示的に固定してください。
localStorageかhttpOnly Cookieか
トークンをlocalStorageに保存すると、ページ上のどのスクリプトからも読み取れるようになります。XSSのバグが1つ、あるいは侵害されたサードパーティのスクリプトが1つあれば、トークンは攻撃者に送られ、有効期限が切れるまでどこからでも使われてしまいます。
httpOnly CookieはJavaScriptから読み取れません。注入されたスクリプトはページが開いている間ユーザーとしてリクエストを送れるため、XSSは依然として深刻ですが、長期間有効な認証情報を盗んであとから再生することはできません。自前のバックエンドと通信するブラウザアプリには、Cookieのほうが既定として優れています。別のAPIを呼び出すシングルページアプリでは、アクセストークンをメモリ内だけに保持し、リフレッシュトークンをhttpOnly Cookieに置くのが妥当な中間解です。
// Express:安全な属性を付けたセッションCookie
res.cookie('__Host-session', accessToken, {
httpOnly: true, // JavaScriptからは読めない
secure: true, // HTTPSのみ。__Host- 接頭辞に必須
sameSite: 'lax', // クロスサイトのPOSTでは送信されない
path: '/', // __Host- 接頭辞に必須
maxAge: 10 * 60 * 1000, // ミリ秒。トークンの有効期間に合わせる
});__Host- 接頭辞は、Secureであり、pathが/に設定され、Domain属性を持たないCookieでなければ拒否するようブラウザに伝えるもので、侵害されたサブドメインからの上書きを防ぎます。
SameSiteが守らないもの
SameSite=Laxは、クロスサイトのPOSTリクエストとサブリソースの読み込みでCookieの送信をブロックするため、古典的なCSRFのほとんどを取り除きます。ただしトップレベルのGETナビゲーションでは依然としてCookieを送るため、状態を変更するGETエンドポイントは露出したままです。Strictはそれらもブロックしますが、メールや他サイトのリンクからアプリに来たユーザーをログアウト状態にしてしまいます。Noneは保護を無効にし、Secureが必須になります。
Laxに加えて「GETリクエストは決して状態を変えない」というルールを守れば、健全な土台になります。機微な更新処理には、Originヘッダーの確認かCSRFトークンを追加してください。SameSiteは登録可能ドメインのすべてのサブドメインを同一サイトとして扱うため、脆弱なサブドメインがあればなおリクエストを偽造されうることも覚えておいてください。
ローテーション、失効、そして本物のログアウト
ステートレスなJWTは、有効期限が切れるまで失効させられません。それが、リクエストごとにデータベースを参照しないことの代償です。CWE-613は、この代償を無視したときに起きることを表しています。ログアウト、パスワード変更、アカウント停止が実際にはアクセスを終わらせないのです。アカウントの削除、ロールの変更、チームからの除外も失効イベントであり、いずれも次のトークンの期限切れではなく、次のリクエストで効力を持つべきです。
- アクセストークンは短命に保ちます。日単位ではなく分単位です。
- リフレッシュトークンはハッシュ化してサーバー側に保存し、使うたびにローテーションします。
- すでにローテーション済みのリフレッシュトークンが再度使われたら、盗難とみなしてそのトークンファミリー全体を失効させます。
- ユーザーの行とトークンにトークンバージョンを持たせ、全端末からのログアウト、パスワード変更、アカウント停止のときに繰り上げます。
- セッション固定攻撃を防ぐため、ログイン時にセッション識別子を再生成します。
export async function requireUser(token: string) {
const payload = await verifyAccessToken(token);
const user = await db.user.findUnique({
where: { id: payload.sub },
select: { id: true, tokenVersion: true, disabled: true },
});
// バージョンの繰り上げや無効化されたアカウントは即座にアクセスを終わらせる
if (!user || user.disabled || user.tokenVersion !== payload.tv) {
throw new Error('Session revoked');
}
return user;
}この参照によって、リクエストごとに1回のデータベース読み取りが戻ってきます。これが失効を実現するための正直なコストです。多くのアプリは、Cookieに入れた不透明なセッションIDとセッションのテーブルを使うほうが、単純で安全になります。その場合、ログアウトとは行を1つ削除することです。JWTは、サービス間の短命なトークンのように、その性質が実際に役立つ場面で使ってください。チュートリアルの既定だから、という理由ではありません。
どちらを選ぶにせよ、ログアウトはサーバー側で起きなければなりません。ブラウザでCookieを消したりトークンを削除したりしても、ユーザーの手元のコピーが消えるだけで、盗まれたコピーはサーバーが拒否するまで動き続けます。ログアウト時には、サーバー側のセッションかリフレッシュトークンを削除し、設定時と同じ名前・パス・属性でCookieをクリアし、ユーザーが全端末からのサインアウトを選んだならトークンバージョンを繰り上げてください。パスワードのリセットでも、他のすべてのセッションを終了させるべきです。
短いレビューチェックリスト
- 認証の経路でdecodeの呼び出しを検索する。
- すべての検証呼び出しがアルゴリズム、発行者、対象者を固定し、expを必須にしていることを確認する。
- 署名シークレットがどう生成され、どう読み込まれ、起動時にどう検証されるかを確認する。
- トークンがブラウザのどこに保存されているかを突き止める。
- ログアウト、パスワード変更、アカウント停止が既存のセッションを終わらせることをテストする。
これらの確認は、コードの経路を端から端まで読むことが中心であり、CodeAuditAgentが監査に臨むやり方もまさに同じです。指摘事項には該当行の引用、CWE、攻撃シナリオ、パッチが付くため、対象者の検証漏れも数分で確認して修正できます。