🔐

Web Crypto APIで理解するJWT署名の仕組みと実装

に公開

JWTは現代のWeb認証で広く使われていますが、その内部構造や署名の仕組みを正確に理解している人は意外と少ないかもしれません。この記事では、JWTデコーダーの実装を通じて、Base64URLエンコーディング、HMAC署名、Web Crypto APIの使い方を学んでいきます。

ツールを実際に試す:TechTools - JWTデコーダー

JWTが解決する問題

従来のセッションベース認証では、サーバー側でセッション情報を保持する必要があり、スケーラビリティに課題がありました。JWTは、認証情報をトークン自体に含めることで、ステートレスな認証を実現します。

Base64URLエンコーディングの必要性

JWTでは標準のBase64ではなく、Base64URLを使います。なぜでしょうか?

標準Base64は以下の64文字を使います。A-Z, a-z, 0-9, +, /

しかし、+/はURLで特別な意味を持ちます。JWTはURLパラメータとして渡されることが多いため、これらを安全な文字に置き換えます。+-, /_, さらにパディング=も削除します。

実装を見てみましょう。

const base64UrlEncode = (buffer: ArrayBuffer): string => {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (let i = 0; i < bytes.length; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
};

デコード時は逆の処理を行いますが、パディングの復元が必要です。

const base64UrlDecode = (str: string): Uint8Array => {
  const padding = '='.repeat((4 - (str.length % 4)) % 4);
  const base64 = str.replace(/-/g, '+').replace(/_/g, '/') + padding;

  const binary = atob(base64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return bytes;
};

パディング計算の(4 - (str.length % 4)) % 4という式は、4の倍数になるまで=を追加する計算です。

HMAC署名の生成

JWTの署名は、HMAC(Hash-based Message Authentication Code)を使います。

署名の目的は2つあります。1. 改ざん検出、2. 送信者認証

Web Crypto APIを使った実装を見ていきます。

const generateSignature = async (
  header: any,
  payload: any,
  secretKey: string
): Promise<string> => {
  const algorithm = header.alg?.toUpperCase();

  const hashAlgorithm = algorithm === 'HS256' ? 'SHA-256' :
                       algorithm === 'HS384' ? 'SHA-384' : 'SHA-512';

  const headerB64 = encodeObject(header);
  const payloadB64 = encodeObject(payload);

  const encoder = new TextEncoder();
  const keyData = encoder.encode(secretKey);
  const cryptoKey = await crypto.subtle.importKey(
    'raw',
    keyData,
    { name: 'HMAC', hash: hashAlgorithm },
    false,
    ['sign']
  );

  const data = encoder.encode(`${headerB64}.${payloadB64}`);

  const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);

  const signatureB64 = base64UrlEncode(signature);

  return `${headerB64}.${payloadB64}.${signatureB64}`;
};

ステップを追っていきましょう。

  1. アルゴリズムの決定: HS256 → SHA-256, HS384 → SHA-384, HS512 → SHA-512
  2. ヘッダーとペイロードをBase64URLエンコード
  3. シークレットキーをCryptoKeyにインポート
  4. header.payloadを署名対象データとして生成
  5. HMAC署名を計算
  6. 署名をBase64URLエンコード

crypto.subtle.importKey()の引数を詳しく見ます。

await crypto.subtle.importKey(
  'raw',                                      // 形式: 生のバイト列
  keyData,                                    // シークレットキーのバイト配列
  { name: 'HMAC', hash: hashAlgorithm },     // アルゴリズム設定
  false,                                      // エクスポート不可
  ['sign']                                    // 用途: 署名のみ
);

extractable: falseにより、この鍵はエクスポートできません。セキュリティ上の理由です。

署名の検証

署名の検証は、同じプロセスで署名を再計算し、元の署名と一致するか確認します。

const verifySignature = async (
  token: string,
  secretKey: string
): Promise<boolean> => {
  const parts = token.split('.');
  if (parts.length !== 3) return false;

  const [headerB64, payloadB64, signatureB64] = parts;

  const headerBytes = base64UrlDecode(headerB64);
  const header = JSON.parse(new TextDecoder().decode(headerBytes));

  const algorithm = header.alg?.toUpperCase();
  if (!['HS256', 'HS384', 'HS512'].includes(algorithm)) {
    return false;
  }

  const hashAlgorithm = algorithm === 'HS256' ? 'SHA-256' :
                       algorithm === 'HS384' ? 'SHA-384' : 'SHA-512';

  const encoder = new TextEncoder();
  const keyData = encoder.encode(secretKey);
  const cryptoKey = await crypto.subtle.importKey(
    'raw',
    keyData,
    { name: 'HMAC', hash: hashAlgorithm },
    false,
    ['verify']  // 検証用途
  );

  const data = encoder.encode(`${headerB64}.${payloadB64}`);

  const signatureBytes = base64UrlDecode(signatureB64);

  const isValid = await crypto.subtle.verify(
    'HMAC',
    cryptoKey,
    signatureBytes,
    data
  );

  return isValid;
};

crypto.subtle.verify()は、署名の検証を行います。内部的には、同じデータとキーで署名を再計算し、一致するか確認しています。

JWTのセキュリティモデル

JWTは暗号化されていません。Base64エンコードされているだけです。つまり、誰でもデコードして中身を見ることができます。

それでも安全なのは、「署名」があるからです。

署名により以下が保証されます。1. データが改ざんされていない、2. 署名者がシークレットキーを持っている

しかし、以下は保証されません。1. データの機密性(暗号化されていない)、2. トークンの無効化(ステートレスのため)

そのため、以下のベストプラクティスが重要です。

// ❌ 機密情報を含めない
const payload = {
  password: 'secret123',  // ダメ!
  creditCard: '1234-5678' // ダメ!
};

// ✅ 識別子のみ
const payload = {
  sub: userId,
  name: 'John Doe',
  iat: Math.floor(Date.now() / 1000),
  exp: Math.floor(Date.now() / 1000) + (60 * 60) // 1時間後
};

有効期限(exp)を設定することで、トークンが漏洩した場合のリスクを限定できます。

RSA署名との違い

この記事ではHMAC署名を扱いましたが、JWTはRSA署名(RS256など)もサポートしています。

HMAC(HS256):秘密鍵を使って署名・検証、同じ鍵を共有する必要がある

RSA(RS256):秘密鍵で署名、公開鍵で検証、鍵の共有不要

マイクロサービスアーキテクチャでは、各サービスが公開鍵だけ持てばいいため、RS256がよく使われます。

まとめ

JWTの実装を通じて、以下を学びました。

  1. Base64URLエンコーディングの必要性と実装
  2. Web Crypto APIによるHMAC署名の生成・検証
  3. JWTのセキュリティモデルと制約

JWTはステートレス認証を実現する強力なツールですが、その特性と制約を理解して使うことが重要です。

ツールを実際に試す:TechTools - JWTデコーダー

Discussion