JWT認証:JSON Web Tokenの仕組み

· 12分で読む

目次

JSON Web Tokenとは?

JSON Web Token(JWT)は、2つの当事者間でクレームを表現するためのコンパクトでURLセーフな方法です。JWTは、現代のWebアプリケーション、API、マイクロサービスアーキテクチャにおける認証と認可の事実上の標準となっています。

ユーザーがログインすると、サーバーはユーザー情報を含むJWTを生成し、クライアントに送信します。その後、クライアントは後続のリクエストにこのトークンを含めて、自分の身元を証明します。これは、あなたの資格情報を含み、毎回発行機関に問い合わせることなく検証できるデジタルパスポートのようなものと考えてください。

サーバーがメモリやデータベースにセッション状態を保持する従来のセッションベース認証とは異なり、JWTはステートレスです。トークンを検証するために必要なすべての情報は、トークン自体に含まれています。このアーキテクチャ上の決定には、いくつかの利点があります:

コードを書かなくても、JWTデコーダーツールを使用して任意のJWTを検査およびデコードし、その内容を確認できます。これは、認証の問題をデバッグしたり、トークンに含まれるデータを理解したりする際に特に便利です。

プロのヒント:JWTはデフォルトで署名されていますが、暗号化されていません。誰でもJWTをデコードして内容を読むことができます。パスワード、クレジットカード番号、社会保障番号などの機密情報をJWTペイロードに保存しないでください。

JWT構造の解説

JWTは、ドット(.)で区切られた3つの異なる部分で構成されています:ヘッダー、ペイロード、署名です。実際のJWTは次のようになります:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZW1haWwiOiJqb2huQGV4YW1wbGUuY29tIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyNDI2MjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

各コンポーネントを分解して、安全で検証可能なトークンを作成するためにどのように連携するかを理解しましょう。

1. ヘッダー

ヘッダーには通常、トークンの処理方法を定義する2つの重要なフィールドが含まれています:

{
  "alg": "HS256",
  "typ": "JWT"
}

このJSONオブジェクトは、JWTの最初の部分を形成するためにBase64Urlエンコードされます。エンコーディングにより、HTTPヘッダーでの送信に適したURLセーフでコンパクトな形式になります。

2. ペイロード

ペイロードにはクレーム(ユーザーに関する記述と追加のメタデータ)が含まれます。これは、送信したい実際のデータを保存する場所です:

{
  "sub": "1234567890",
  "name": "John Doe",
  "email": "[email protected]",
  "role": "admin",
  "iat": 1516239022,
  "exp": 1516242622
}

クレームは3つのカテゴリに分類されます:

登録済みクレーム(標準化され推奨される):

パブリッククレームは、IANA JSON Web Tokenレジストリで定義されるか、衝突耐性のある名前(URLなど)を使用する必要があるカスタムクレームです。

プライベートクレームは、rolepermissionsdepartmentなどの当事者間で合意されたカスタムクレームです。

3. 署名

署名は、JWTを安全で改ざん防止にするものです。エンコードされたヘッダー、エンコードされたペイロード、秘密鍵を取り、ヘッダーで指定されたアルゴリズムを適用することで作成されます:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

署名は2つの重要な目的を果たします:

  1. 整合性検証:トークンが発行されてから変更されていないことを保証
  2. 認証:トークンが秘密鍵にアクセスできる人によって作成されたことを証明

誰かがヘッダーやペイロードを変更しようとすると、署名検証が失敗し、トークンは拒否されます。

JWT認証の仕組み

JWTを正しく実装するには、完全な認証フローを理解することが不可欠です。ステップバイステップのプロセスは次のとおりです:

ステップ1:ユーザーログイン

ユーザーは認証エンドポイントに資格情報(ユーザー名とパスワード)を送信します。サーバーはこれらの資格情報をデータベースに対して検証します。

ステップ2:トークン生成

資格情報が有効な場合、サーバーはユーザー情報とクレームを含むJWTを生成します。サーバーは秘密鍵(対称アルゴリズムの場合)または秘密鍵(非対称アルゴリズムの場合)を使用してトークンに署名します。

ステップ3:トークン配信

サーバーはJWTをクライアントに返送します。通常、レスポンスボディに含まれます。クライアントは将来のリクエストのためにこのトークンを保存します。

ステップ4:認証されたリクエスト

後続のリクエストでは、クライアントはBearerスキーマを使用してAuthorizationヘッダーにJWTを含めます:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

ステップ5:トークン検証

サーバーはリクエストを受信し、ヘッダーからJWTを抽出し、秘密鍵または公開鍵を使用して署名を検証します。有効な場合、サーバーはペイロードからユーザー情報を抽出し、リクエストを処理します。

ステップ6:トークンの有効期限

トークンが期限切れになると、クライアントは再認証するか、リフレッシュトークンを使用して(次のセクションで説明)新しいトークンを取得する必要があります。

クイックヒント:クライアント側で有効期限をチェックしても、サーバー側で常にexpクレームを検証してください。クライアント側のチェックはバイパスできますが、サーバー側の検証は権威があります。

アクセストークンとリフレッシュトークン

堅牢なJWT認証システムは通常、連携する2種類のトークンを使用します:アクセストークンとリフレッシュトークンです。この違いを理解することは、安全なアプリケーションを構築するために重要です。

アクセストークン

アクセストークンは、保護されたリソースへのアクセスを許可する短命のJWT(通常15分から1時間)です。リクエストを認可するために必要なユーザーIDと権限が含まれています。

特徴:

リフレッシュトークン

リフレッシュトークンは、新しいアクセストークンを取得するためだけに使用される長命のトークン(数日から数ヶ月)です。安全に保存され、トークンリフレッシュエンドポイントにのみ送信されます。

特徴:

なぜ両方を使用するのか?

この二重トークンアプローチは、セキュリティとユーザーエクスペリエンスのバランスを取ります:

側面 アクセストークンのみ アクセス+リフレッシュトークン
セキュリティ 低い(長命トークンがリスク) 高い(短命アクセストークン)
ユーザーエクスペリエンス 良い(再認証不要) 良い(シームレスなトークンリフレッシュ)
取り消し 困難(ステートレス) 可能(DBのリフレッシュトークン)
ネットワークオーバーヘッド 低い やや高い(リフレッシュリクエスト)
実装の複雑さ シンプル 中程度

トークンリフレッシュフロー

  1. クライアントはアクセストークンが期限切れまたは期限切れ間近であることを検出
  2. クライアントは/auth/refreshエンドポイントにリフレッシュトークンを送信
  3. サーバーはデータベースに対してリフレッシュトークンを検証
  4. サーバーは新しいアクセストークン(およびオプションで新しいリフレッシュトークン)を生成
  5. クライアントは新しいトークンを受信して保存
  6. クライアントは新しいアクセストークンで元のリクエストを再試行

プロのヒント:リフレッシュトークンのローテーションを実装してください — 使用されるたびに新しいリフレッシュトークンを発行し、古いものを無効化します。これにより、リフレッシュトークンが侵害された場合の被害を制限できます。

Node.js実装ガイド

Node.js、Express、jsonwebtokenライブラリを使用して、完全なJWT認証システムを構築しましょう。この実装には、アクセストークンとリフレッシュトークンの両方が含まれます。

インストール

npm install express jsonwebtoken bcrypt dotenv

環境設定

秘密鍵を含む.envファイルを作成します:

ACCESS_TOKEN_SECRET=your-super-secret-access-key-change-this
REFRESH_TOKEN_SECRET=your-super-secret-refresh-key-change-this
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d

トークン生成

const jwt = require('jsonwebtoken');

function generateAccessToken(user) {
  return jwt.sign(
    { 
      userId: user.id,
      email: user.email,
      role: user.role 
    },
    process.env.ACCESS_TOKEN_SECRET,
    { expiresIn: process.env.ACCESS_TOKEN_EXPIRY }
  );
}

function generateRefreshToken(user) {
  return jwt.sign(
    { userId: user.id },
    process.env.REFRESH_TOKEN_SECRET,
    { expiresIn: process.env.REFRESH_TOKEN_EXPIRY }
  );
}

ログインエンドポイント

const bcrypt = require('bcrypt');

app.post('/auth/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    
    // データベースでユーザーを検索
    const user = await User.findOne({ email });
    if (!user) {
      return res.status(401).json({ error: '無効な資格情報' });
    }
    
    // パスワードを検証
    const validPassword = await bcrypt.compare(password, user.password);
    if (!validPassword) {
      return res.status(401).json({ error: '無効な資格情報' });
    }
    
    // トークンを生成
    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user);
    
    // データベースにリフレッシュトークンを保存
    await RefreshToken.create({
      token: refreshToken,
      userId: user.id,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    });
    
    res.json({
      accessToken,
      refreshToken,
      user: {
        id: user.id,
        email: user.email,
        name: user.name
      }
    });
  } catch (error) {
    res.status(500).json({ error: 'サーバーエラー' });
  }
});

認証ミドルウェア

function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: 'アクセストークンが必要です' });
  }
  
  jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
    if (err) {
      return res.status(403).json({ error: '無効または期限切れのトークン' });
    }
    
    req.user = user;
    next();
  });
}

// 使用方法
app.get('/api/protected', authenticateToken, (req, res) => {
  res.json({ 
    message: '保護されたデータ',
    user: req.user 
  });
});

トークンリフレッシュエンドポイント

app.post('/auth/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  
  if (!refreshToken) {
    return res.status(401).json({ error: 'リフレッシュトークンが必要です' });
  }
  
  try {
    // データベースにリフレッシュトークンが存在することを確認
    const storedToken = await RefreshToken.findOne({ token: refreshToken });
    if (!storedToken) {
      return res.status(403).json({ error: '無効なリフレッシュトークン' });
    }
    
    // トークン署名を検証
    jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, async (err, user) => {
      if (err) {
        return res.status(403).json({ error: '無効なリフレッシュトークン' });
      }
      
      // ユーザーデータを取得
      const userData = await User.findById(user.userId);
      
      // 新しいアクセストークンを生成
      const accessToken = generateAccessToken(userData);
      
      res.json({ accessToken });