JWT認証の仕組みと実装|トークンベース認証の基本から実践まで

kento_morota 20分で読めます

「セッションベース認証からトークンベース認証に移行したいけれど、JWTの使い方がよくわからない」——APIベースのアプリケーション開発が主流となった現在、多くのエンジニアがこうした課題に直面しています。

JWT(JSON Web Token)は、ステートレスな認証を実現するための標準仕様です。本記事では、JWTの基本構造から安全な実装方法、そして運用時の注意点までを実践的に解説します。

JWTとは何か?基本概念を理解する

JWT(JSON Web Token、読み方:ジョット)は、RFC 7519で標準化された、二者間で安全にクレーム(情報)を伝達するためのコンパクトなトークン形式です。

Webアプリケーションにおいて、JWTは主に以下の目的で使用されます。

認証(Authentication)
ユーザーがログインに成功すると、サーバーがJWTを発行します。以降のリクエストでは、このJWTをHTTPヘッダーに含めて送信することで、ユーザーの認証状態を維持します。

情報の安全な伝達
JWTは署名されているため、内容が改ざんされていないことを検証できます。マイクロサービス間でユーザー情報を安全に共有する場合などに活用されます。

セッションベース認証との違い

従来のセッションベース認証とJWTベース認証の違いを理解しておきましょう。

セッションベース認証

・サーバーがセッション情報をメモリやデータベースに保存する
・クライアントはセッションIDのみを保持する
・サーバー側でセッションの有効性を管理する(ステートフル)
・スケールアウト時にセッション共有の仕組みが必要

JWTベース認証

・サーバーはトークンを保存しない(ステートレス)
・クライアントがトークンを保持し、リクエストごとに送信する
・トークン自体にユーザー情報が含まれている
・サーバーのスケールアウトが容易

JWTの構造を詳しく見る

JWTは「ヘッダー」「ペイロード」「署名」の3つの部分で構成され、それぞれBase64URLエンコードされてドット(.)で連結されます。

// JWTの構造
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.    // ヘッダー
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IuW.  // ペイロード
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c // 署名

ヘッダー(Header)

トークンのタイプと署名アルゴリズムを指定します。

// ヘッダーのJSON(Base64URLデコード後)
{
  "alg": "HS256",  // 署名アルゴリズム
  "typ": "JWT"     // トークンタイプ
}

主な署名アルゴリズムは以下の通りです。

HS256(HMAC-SHA256):共通鍵を使用する対称鍵アルゴリズム。発行者と検証者が同一の場合に適しています。

RS256(RSA-SHA256):公開鍵・秘密鍵のペアを使用する非対称鍵アルゴリズム。秘密鍵で署名し、公開鍵で検証します。マイクロサービス環境で推奨されます。

ES256(ECDSA-SHA256):楕円曲線暗号を使用する非対称鍵アルゴリズム。RS256よりも鍵サイズが小さく、パフォーマンスに優れています。

ペイロード(Payload)

ペイロードにはクレーム(Claim)と呼ばれる情報が格納されます。

// ペイロードの例
{
  // 登録済みクレーム(標準仕様で定義)
  "iss": "https://api.example.com",   // 発行者
  "sub": "user-12345",                 // 主題(ユーザーID)
  "aud": "https://app.example.com",   // 対象者
  "exp": 1711540800,                   // 有効期限(Unix時間)
  "iat": 1711537200,                   // 発行日時
  "nbf": 1711537200,                   // 有効開始日時
  "jti": "unique-token-id-abc123",     // トークン固有ID

  // カスタムクレーム(アプリ固有の情報)
  "role": "admin",
  "email": "user@example.com"
}

重要:ペイロードは暗号化されていません。Base64URLエンコードされているだけなので、誰でもデコードして中身を読むことができます。パスワードやクレジットカード番号などの機密情報は絶対にペイロードに含めないでください。

署名(Signature)

署名は、ヘッダーとペイロードが改ざんされていないことを保証します。

// HS256での署名生成(概念)
signature = HMAC-SHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

// RS256での署名生成(概念)
signature = RSA-SHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  privateKey
)

JWTを使った認証フローの実装

実際のアプリケーションにおけるJWT認証の実装方法を、Node.js(Express)を例に解説します。

ログインとトークン発行

const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');

const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;

// ログインエンドポイント
app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;

  // 1. ユーザーの検索
  const user = await User.findByEmail(email);
  if (!user) {
    return res.status(401).json({ error: 'メールアドレスまたはパスワードが正しくありません' });
  }

  // 2. パスワードの検証
  const isValid = await bcrypt.compare(password, user.passwordHash);
  if (!isValid) {
    return res.status(401).json({ error: 'メールアドレスまたはパスワードが正しくありません' });
  }

  // 3. アクセストークンの生成(短い有効期限)
  const accessToken = jwt.sign(
    { sub: user.id, email: user.email, role: user.role },
    ACCESS_TOKEN_SECRET,
    { expiresIn: '15m', issuer: 'https://api.example.com' }
  );

  // 4. リフレッシュトークンの生成(長い有効期限)
  const refreshToken = jwt.sign(
    { sub: user.id, jti: generateTokenId() },
    REFRESH_TOKEN_SECRET,
    { expiresIn: '7d', issuer: 'https://api.example.com' }
  );

  // 5. リフレッシュトークンをデータベースに保存
  await RefreshToken.create({
    token: refreshToken,
    userId: user.id,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
  });

  // 6. レスポンス
  res.json({
    accessToken,
    refreshToken,
    expiresIn: 900, // 15分(秒単位)
  });
});

認証ミドルウェア

// JWT認証ミドルウェア
function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"

  if (!token) {
    return res.status(401).json({ error: '認証が必要です' });
  }

  try {
    const payload = jwt.verify(token, ACCESS_TOKEN_SECRET, {
      issuer: 'https://api.example.com',
      algorithms: ['HS256'], // アルゴリズムを明示的に指定
    });

    req.user = {
      id: payload.sub,
      email: payload.email,
      role: payload.role,
    };
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'トークンの有効期限が切れています' });
    }
    return res.status(403).json({ error: '無効なトークンです' });
  }
}

// 使用例
app.get('/api/profile', authenticateToken, (req, res) => {
  res.json({ userId: req.user.id, email: req.user.email });
});

トークンのリフレッシュ

// リフレッシュエンドポイント
app.post('/api/token/refresh', async (req, res) => {
  const { refreshToken } = req.body;

  if (!refreshToken) {
    return res.status(401).json({ error: 'リフレッシュトークンが必要です' });
  }

  // 1. リフレッシュトークンの検証
  let payload;
  try {
    payload = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, {
      issuer: 'https://api.example.com',
      algorithms: ['HS256'],
    });
  } catch (err) {
    return res.status(403).json({ error: '無効なリフレッシュトークンです' });
  }

  // 2. データベースでトークンの存在を確認
  const storedToken = await RefreshToken.findByToken(refreshToken);
  if (!storedToken) {
    // トークンがDBにない場合、不正利用の可能性
    // このユーザーの全リフレッシュトークンを無効化
    await RefreshToken.revokeAllByUserId(payload.sub);
    return res.status(403).json({ error: '不正なトークンです。再ログインしてください' });
  }

  // 3. 古いリフレッシュトークンを削除
  await RefreshToken.delete(storedToken.id);

  // 4. 新しいトークンペアを発行(リフレッシュトークンローテーション)
  const newAccessToken = jwt.sign(
    { sub: payload.sub, email: storedToken.user.email, role: storedToken.user.role },
    ACCESS_TOKEN_SECRET,
    { expiresIn: '15m', issuer: 'https://api.example.com' }
  );

  const newRefreshToken = jwt.sign(
    { sub: payload.sub, jti: generateTokenId() },
    REFRESH_TOKEN_SECRET,
    { expiresIn: '7d', issuer: 'https://api.example.com' }
  );

  await RefreshToken.create({
    token: newRefreshToken,
    userId: payload.sub,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
  });

  res.json({
    accessToken: newAccessToken,
    refreshToken: newRefreshToken,
    expiresIn: 900,
  });
});

クライアント側のトークン管理

JWTをクライアント側でどこに保存し、どのようにリクエストに含めるかは、セキュリティ上の重要な判断ポイントです。

保存場所の比較

メモリ(変数)に保存する方法

・XSSによる窃取リスクが最も低い
・ページリロードでトークンが失われる
・推奨度:アクセストークンの保存先として最も安全

HttpOnly Cookieに保存する方法

・JavaScriptからアクセスできないためXSSに強い
・CSRF対策が別途必要
・推奨度:リフレッシュトークンの保存先として推奨

localStorageに保存する方法

・ページリロードしても保持される
・XSS攻撃で簡単に窃取される
・推奨度:非推奨

推奨パターンの実装

// クライアント側:アクセストークンをメモリに、リフレッシュトークンをHttpOnly Cookieに
class AuthManager {
  #accessToken = null;

  async login(email, password) {
    const response = await fetch('/api/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
      credentials: 'same-origin', // Cookieを受け取るために必要
    });

    const data = await response.json();
    this.#accessToken = data.accessToken;
    // リフレッシュトークンはサーバーがHttpOnly Cookieとして設定

    // アクセストークンの有効期限前に自動更新をスケジュール
    this.scheduleRefresh(data.expiresIn);
  }

  async authenticatedFetch(url, options = {}) {
    // アクセストークンが切れている場合はリフレッシュ
    if (!this.#accessToken) {
      await this.refreshToken();
    }

    const response = await fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${this.#accessToken}`,
      },
      credentials: 'same-origin',
    });

    // 401の場合はトークンをリフレッシュしてリトライ
    if (response.status === 401) {
      await this.refreshToken();
      return fetch(url, {
        ...options,
        headers: {
          ...options.headers,
          'Authorization': `Bearer ${this.#accessToken}`,
        },
        credentials: 'same-origin',
      });
    }

    return response;
  }

  async refreshToken() {
    const response = await fetch('/api/token/refresh', {
      method: 'POST',
      credentials: 'same-origin', // HttpOnly Cookieを送信
    });

    if (!response.ok) {
      this.#accessToken = null;
      window.location.href = '/login';
      return;
    }

    const data = await response.json();
    this.#accessToken = data.accessToken;
    this.scheduleRefresh(data.expiresIn);
  }

  scheduleRefresh(expiresIn) {
    // 有効期限の80%のタイミングで更新
    const refreshTime = expiresIn * 0.8 * 1000;
    setTimeout(() => this.refreshToken(), refreshTime);
  }
}

JWTのセキュリティ上の注意点

JWTを安全に運用するために、以下のセキュリティ上の注意点を必ず押さえておきましょう。

algヘッダーの検証

JWTライブラリの過去の脆弱性として有名な攻撃手法があります。攻撃者がalgを「none」に変更すると、署名なしのトークンが受け入れられてしまう問題です。

// 危険な実装:アルゴリズムをトークン側の指定に任せる
const payload = jwt.verify(token, secret); // 危険!

// 安全な実装:受け入れるアルゴリズムを明示的に指定する
const payload = jwt.verify(token, secret, {
  algorithms: ['HS256'], // 明示的に指定
});

トークンの無効化(ログアウト)

JWTはステートレスであるため、発行済みのトークンを即座に無効化することが本来できません。この制約への対処法を紹介します。

// 方法1:短い有効期限 + リフレッシュトークンの無効化
app.post('/api/logout', authenticateToken, async (req, res) => {
  // リフレッシュトークンをDBから削除
  await RefreshToken.revokeAllByUserId(req.user.id);
  // アクセストークンは有効期限(15分)まで有効だが、
  // リフレッシュできなくなるため、短時間でアクセス不能になる
  res.json({ message: 'ログアウトしました' });
});

// 方法2:ブラックリスト(即座に無効化が必要な場合)
const tokenBlacklist = new Set(); // 本番ではRedisを使用

app.post('/api/logout', authenticateToken, async (req, res) => {
  // トークンのjtiをブラックリストに追加
  tokenBlacklist.add(req.tokenJti);
  await RefreshToken.revokeAllByUserId(req.user.id);
  res.json({ message: 'ログアウトしました' });
});

// ミドルウェアでブラックリストを確認
function authenticateToken(req, res, next) {
  // ... トークン検証 ...
  if (tokenBlacklist.has(payload.jti)) {
    return res.status(401).json({ error: 'トークンは無効化されています' });
  }
  // ...
}

ペイロードサイズに注意する

JWTはリクエストごとに送信されるため、ペイロードが大きくなるとパフォーマンスに影響します。

// 悪い例:不要な情報を含めすぎ
{
  "sub": "user-123",
  "name": "山田太郎",
  "email": "yamada@example.com",
  "address": "東京都渋谷区...",      // 不要
  "phone": "090-xxxx-xxxx",           // 不要(機密情報)
  "permissions": ["read", "write", "delete", "admin", ...], // 多すぎる
  "loginHistory": [...]               // 不要
}

// 良い例:最小限の情報のみ
{
  "sub": "user-123",
  "role": "admin",
  "email": "yamada@example.com"
}
// 詳細情報はAPIで別途取得する

JWTとセッションの使い分け

JWTはすべてのケースで最適な選択とは限りません。用途に応じてセッションベースの認証と使い分けることが重要です。

JWTが適しているケース

・RESTful APIの認証
・マイクロサービス間の認証
・モバイルアプリのバックエンド認証
・サーバーのスケールアウトが頻繁な環境
・複数ドメインにまたがるシングルサインオン(SSO)

セッションが適しているケース

・サーバーサイドレンダリングのWebアプリ
・トークンの即座の無効化が必要な場合
・シンプルな小規模アプリケーション
・セッションデータの頻繁な更新が必要な場合

重要なのは、技術のトレンドに流されず、自社のアプリケーションの要件に合った方式を選択することです。

まとめ:安全なJWT認証を実装するために

JWTは、ステートレスな認証を効率的に実現できる強力な技術です。しかし、適切に実装しなければセキュリティリスクが高まることも事実です。

本記事の要点を整理します。

基本を押さえる
・JWTはヘッダー・ペイロード・署名の3部構成
・ペイロードは暗号化されていないため、機密情報を含めない
・署名アルゴリズムは用途に応じてHS256/RS256/ES256を選択する

セキュリティを確保する
・アクセストークンの有効期限は短く設定する(15分程度)
・リフレッシュトークンローテーションを実装する
・algヘッダーの検証を必ず行う
・アクセストークンはメモリに、リフレッシュトークンはHttpOnly Cookieに保存する

運用を見据える
・ログアウト時のトークン無効化戦略を決めておく
・ペイロードは最小限にとどめる
・JWTとセッションの特性を理解し、適切に使い分ける

これらのポイントを踏まえて実装することで、安全で拡張性の高い認証基盤を構築できます。

#JWT#認証#トークン
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

起業準備に役立つ情報、もっとありますよ。

まずは話だけ聞いてもらう