JWT 認証ベストプラクティス:アルゴリズム・保存・失効

安全指南

乱用されがちな JWT

JWT は「ログインが必要」のデフォルト選択になり下がったが、コードレビューで目にする誤りは決まっている。ユーザーのパスワードを payload に詰め込む、脆弱な alg: none のライブラリ版を使う、token を localStorage に入れてなぜ XSS で盗まれるか不思議がる、期限のない token を発行して失効できない、など。

JWT 自体に悪いところはない。「それが万能の session 代替だ」と勘違いするのが問題だ。本番で本当に痛い点をここにまとめる。


1. JWT とは何か

JWT は . でつないだ三部分からなる:header.payload.signature

  • header:アルゴリズムと型。例 {"alg":"HS256","typ":"JWT"}
  • payload:クレーム。例 {"sub":"123","name":"Ada","exp":1700000000}
  • signature:前半二つを秘密鍵で署名し、改ざんを防ぐ

重要:payload は Base64Url エンコードされているだけで、誰でもデコードして読める。暗号化ではない。パスワードやマイナンバー、token そのものを入れてはいけない。

echo 'eyJzdWIiOiIxMjMifQ' | base64 -d
# → {"sub":"123"}

2. 三つのよくある誤り

誤り1:JWT を session として使う

従来の session は状態をサーバー側に持ち、ユーザーの失効はサーバー側で session を消すだけだ。JWT は既定でステートレス——サーバーは保持しないので「失効」が難しくなる。頻繁な失効や強制ログアウトが必要なら、JWT は session より簡単ではなく、むしろ厄介だ(第6節参照)。

誤り2:payload への機密データ

payload は読めるので、パスワードや平文メール、内部権限位を入れるのは漏洩リスクだ。必要最小・非機密の識別子だけを入れる:sub(ユーザーID)、roleexp

誤り3:期限のない token

exp なし、あるいは数年後の exp は永久通行証だ。漏洩すれば攻撃者はずっと使える。


3. アルゴリズムの安全:攻撃はここから

none アルゴリズム攻撃

古いライブラリのなかには alg: none のとき署名検証をスキップしたものがあった。攻撃者は header を {"alg":"none"} に変え、signature を空にして任意の payload を偽造する。

防御:サーバーは許可アルゴリズムを白リストで明示し、none を拒否する。

jwt.verify(token, secret, { algorithms: ["HS256"] });

クライアントにアルゴリズムを決めさせてはならない。

RS256 / HS256 混同攻撃

古典的な「アルゴリズム混同」:サーバーは RSA 公開鍵で検証(RS256 期待)しているが、攻撃者は header を HS256 に変え、公開鍵を HMAC の秘密鍵として署名する。公開鍵はたいてい公開されているので、攻撃者は正当な token を偽造できる。

防御:

  • 検証時にアルゴリズム鍵の種類を固定する:RS256 は公開鍵で、HS256 は対称鍵で。
  • RSA 公開鍵を HMAC 秘密鍵に使ってはならない。
  • 成熟ライブラリ(jsonwebtoken、jose)を使い、algorithms を明示する。
jwt.verify(token, publicKey, { algorithms: ["RS256"] });

鍵管理

  • 対称(HS256)の secret は長く、乱数で、リポジトリ外(環境変数/シークレットマネージャ)に。
  • 非対称(RS256/ES256)の秘密鍵は署名サーバーのみ、公開鍵は配布可。
  • 鍵ローテーションを支援(kid ヘッダで現鍵を識別)。

4. 有効期限とリフレッシュ:access + refresh 設計

ベストプラクティスは token を二種に分ける:

  • access token:短期(例 15 分)、API 呼び出しに使用、漏洩窓は小さい。
  • refresh token:長期(例 7 日)、新しい access token を発行するためだけ、より安全に保存。
const accessToken = jwt.sign({ sub: user.id, role }, accessSecret, {
  algorithm: "RS256",
  expiresIn: "15m",
});
const refreshToken = jwt.sign({ sub: user.id, jti: refreshId }, refreshSecret, {
  algorithm: "RS256",
  expiresIn: "7d",
});

リフレッシュエンドポイントは refresh token のみ受け付け、**毎回新しい refresh token を発行(ローテーション)**し、古いものを無効化する。こうすれば refresh token が漏れても一度使えば置き換わる。


これは XSS と CSRF のトレードオフだ。

localStorage のリスク

localStorage の token は任意の JS から読める。XSS が一つあれば(脆弱なサードパーティスクリプトでも)、token は攻撃者サーバーへ持ち出される。localStorageHttpOnly にできず、「HTTP 専用」と mark できない。

Cookie(HttpOnly + Secure + SameSite)のほうが安全

access token を Cookie に入れる:

  • HttpOnly:JS から読めず、XSS 窃取を防ぐ。
  • Secure:HTTPS のみ。
  • SameSite=Strict/Lax:大半の CSRF を防ぐ。

代償は CSRF 対応(第7節)。大半の Web アプリでは localStorage より安全な既定だ。

res.setHeader("Set-Cookie", [
  `access=${accessToken}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=900`,
]);

折衷案

  • SPA + 同一オリジンbackend:access を HttpOnly Cookie に、フロントは fetch で自動付与。
  • モバイル/サードパーティ呼び出し:Authorization ヘッダ + 短期 access token、refresh は安全な保存へ。

6. 失効とブラックリスト:jti

短期 access + ローテーション refresh で大半のリスクは抑えられる。しかし「今すぐこのユーザーを追い出す」には?各 token に一意の jti(JWT ID)を振り、「失効リスト」(Redis ブラックリスト、TTL は token の残り寿命)を管理する:

await redis.set(`blacklist:${jti}`, "1", "EX", remainingSeconds);
if (await redis.get(`blacklist:${jti}`)) throw new Error("revoked");

サーバーに全 token を保存する必要はなく、「失効済」だけを保存すればよい。コストは極小。ブラックリストがカバーするのは「能動的に失効させたい」場面(BAN、パスワード変更)のみ。通常の期限切れは引き続き exp 任せ。


7. CSRF 対策と SameSite

Cookie を使うなら CSRF を考慮する。攻撃者がユーザーのブラウザを誘導して自ドメインへリクエストを送らせ、ブラウザが Cookie を自動付与して、ユーザーになりすまして操作させる。

  • SameSite=Lax/Strict:現代ブラウザは既定で大半のクロスサイト Cookie 付与を防ぐ。優先は SameSite=Lax
  • CSRF Token:機微な書き込みには、サーバー発行のランダム token(double-submit)を要求。
  • 機微操作の再認証:パスワード変更や送金などは、Cookie に加え第二段階認証を。
app.post("/api/transfer", requireReauth, handler);

8. 実戦:Node/Express ミドルウェア

最小だが安全なガードミドルウェア:

import jwt from "jsonwebtoken";

export function authGuard(publicKey: string) {
  return (req, res, next) => {
    const token = req.cookies?.access; // HttpOnly Cookie
    if (!token) return res.status(401).json({ error: "no token" });

    let payload;
    try {
      payload = jwt.verify(token, publicKey, { algorithms: ["RS256"] });
    } catch {
      return res.status(401).json({ error: "invalid token" });
    }

    if (blacklist.has(payload.jti)) return res.status(401).json({ error: "revoked" });

    req.user = { id: payload.sub, role: payload.role };
    next();
  };
}

付随のリフレッシュエンドポイント:

app.post("/api/refresh", async (req, res) => {
  const rt = req.cookies?.refresh;
  if (!rt) return res.status(401).json({ error: "no refresh" });
  const payload = jwt.verify(rt, refreshPublicKey, { algorithms: ["RS256"] });
  if (blacklist.has(payload.jti)) return res.status(401).json({ error: "revoked" });

  blacklist.add(payload.jti);                  // 古い refresh を失効
  const newAccess = issueAccess(payload.sub);
  const newRefresh = issueRefresh(payload.sub); // 新 jti
  res.setHeader("Set-Cookie", [accessCookie(newAccess), refreshCookie(newRefresh)]);
  res.json({ ok: true });
});

よくある質問

Q1:JWT と session どちらが良い?

要件次第。サーバー側集中管理、頻繁な追い出し、大きなセッション状態が必要なら session。ステートレス、クロスサービス、モバイル友好なら JWT。人気だからといって無思考に JWT を選ばない。

Q2:access token の有効期限は?

Web アプリなら 5〜15 分が妥当、リフレッシュローテーションと併用。長すぎは漏洩窓が大きく、短すぎは頻繁なリフレッシュで体験を損なう。

Q3:なぜライブラリが「invalid algorithm」と言う?

たいていサーバーは RS256 期待だが HS256 を受け取った、あるいはその逆——アルゴリズムが固定されていない。algorithms: [...] を明示し、RS256 は公開鍵で検証すること。

Q4:フロントどうやってユーザー情報を得る?

機微データのため JWT をデコードしてはいけない(payload は読めるが信頼できず、古い可能性も)。/api/me で権威的なユーザー情報を返し、フロントはユーザーIDなどの非機密識別子だけを保持する。

Q5:JWT は暗号化できる?

できる。JWE(JSON Web Encryption)で payload を暗号化可能。しかし大半のケースは署名(改ざん防止)だけで足りる。暗号化は複雑さを増す。機微データは入れないほうが、暗号化するより楽だ。


おすすめツール

token をデバッグして中身を確認するとき、ToolsKu の以下のツールが役立つ:

  • JWT デコード — token を貼り付けて header/payload を表示(署名検証なし、閲覧のみ)
  • JWT 生成器 — 自分で header/payload/secret を埋めてテスト token を生成
  • HMAC — HS256 署名が一致するか手検証

JWT は session の代替ではなく、別のトレードオフだ。「ステートレス・分散可能」を得る代わりに「失効の難しさ・payload の可読性」を失う。得たものと失ったものを自覚してこそ、本当に使いこなしたと言える。

ブラウザローカルツールを無料で試す →

#JWT#认证#安全#Token#鉴权