セキュリティ

JWTとは?初心者向けに仕組みと実装フローを完全解説

記事内に商品プロモーションを含む場合があります

Web開発を学んでいると必ず出会う「JWT(JSON Web Token)」。

でも、いざ調べてみると…

  • 「トークンベース認証って何?」
  • 「セッション認証との違いがわからない」
  • 「結局どうやって実装すればいいの?」

そんな疑問を持った経験はありませんか?

ぼくもその一人でした。JWTの説明を読んでも「ふんわりした内容ばかり」で、具体的にどう動くのかがイメージできませんでした。

そこでこの記事では、実際のコード例と具体的なフローを使って、JWTを「腑に落ちる」レベルまで解説します。

この記事で分かること

  • JWTとは何か(5分で理解できる)
  • アクセストークンとリフレッシュトークンの使い分け
  • 実装の具体的なフロー(step by step)
  • バックエンド・フロントエンドの実装例
  • セキュリティ対策の注意点

JWTとは?一言で説明

JWT(JSON Web Token)は、「ユーザー情報を安全に運ぶパスポート」のようなものです。

従来のセッション認証では、サーバー側でユーザーのログイン状態を管理していました。

しかしJWTでは、ユーザー情報(userIdなど)をトークン自体に含めて、クライアントが持ち歩きます。

セッション認証 vs JWT認証

セッション認証の場合:

  1. ユーザーがログイン
  2. サーバーがセッションIDを発行してデータベースに保存
  3. クライアントがセッションIDを受け取り保存
  4. API呼び出し時にセッションIDを送信
  5. サーバーがデータベースでセッションIDを確認

JWT認証の場合:

  1. ユーザーがログイン
  2. サーバーがJWTトークンを発行(ユーザー情報を含む)
  3. クライアントがJWTを受け取り保存
  4. API呼び出し時にJWTを送信
  5. サーバーがJWTを検証(データベース不要)

JWTの構造を理解しよう

JWTは以下の3つの部分から構成されています:

[ヘッダー].[ペイロード].[署名]

実際のJWTはこのような文字列になります:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NSIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImlhdCI6MTY3MjUyNzYwMCwiZXhwIjoxNjcyNTMxMjAwfQ.signature_here

JWT.ioで実際に中身を見てみよう

上のJWT文字列をJWT.ioに貼り付けてデコードすると、以下のような構造が見えます:

Screenshot

このように、JWTの中身は簡単に見ることができます。

1. ヘッダー(Header)

トークンの基本情報を含む部分です。

{
  "alg": "HS256",  // 署名アルゴリズム
  "typ": "JWT"     // トークンタイプ
}

2. ペイロード(Payload)

実際のデータを含む部分です。ここにuserIdなどの情報を入れます。

{
  "userId": "12345",           // ユーザーID
  "email": "user@example.com", // メールアドレス
  "iat": 1672527600,           // 発行時刻
  "exp": 1672531200            // 有効期限
}

3. 署名(Signature)

改ざんを防ぐための署名です。サーバーの秘密鍵で作成されます。

重要: ヘッダーとペイロードは暗号化されていません。

jwt.ioの画像でもわかるように、誰でも内容を見ることができるため、パスワードなどの機密情報は絶対に含めてはいけません。

アクセストークンとリフレッシュトークンの仕組み

JWTには通常、2種類のトークンがあります。

これがJWT認証を理解する上で最も重要なポイントです。

アクセストークン(Access Token)

  • 短い有効期限(15分〜1時間程度)
  • 実際のAPI呼び出しに使用
  • 頻繁に使われるため、盗まれるリスクが高い

リフレッシュトークン(Refresh Token)

  • 長い有効期限(数日〜数週間)
  • アクセストークンの再発行にのみ使用
  • 使用頻度が低いため、比較的安全

なぜ2つのトークンが必要なのか

もしアクセストークンの有効期限を長くすると、盗まれた場合のリスクが高くなります。

逆に短くしすぎると、ユーザーが頻繁にログインし直す必要があります。

そこで、短期間のアクセストークンと長期間のリフレッシュトークンを組み合わせることで、セキュリティと利便性を両立させています。

JWT認証の具体的なフロー

実際にJWTがどのように使われるのか、step by stepで見てみましょう。

Step 1: ログイン

ユーザーがメールアドレスとパスワードでログインします。

// フロントエンド → サーバー
POST /api/auth/login
{
  "email": "user@example.com",
  "password": "password123"
}

Step 2: バックエンドでログインの検証

サーバーは受け取った認証情報をデータベースと照合し、正しいかどうかを確認します。

Step 3: バックエンドでJWTのトークンを発行

認証が成功すると、サーバーは2つのJWTを生成します:

アクセストークンのペイロード例:

{
  "userId": "12345",
  "email": "user@example.com",
  "exp": 1672531200  // 1時間後
}

リフレッシュトークンのペイロード例:

{
  "userId": "12345",
  "exp": 1673135200  // 7日後
}

Step 4: フロントにJWTのトークンを保存

サーバーから返されたJWTをクライアント側で保存します。

// サーバー → フロントエンド
{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600
}

フロントエンドはこれらのトークンをlocalStorageやメモリに保存します。

この時点で、JWTにはuserIdなどのユーザー情報が含まれています。

Step 5: ユーザー情報などを取得する際にJWTをAPIのヘッダーに含める

ユーザー情報を取得したい場合、アクセストークンをAuthorizationヘッダーに含めて送信します:

GET /api/user/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

Step 6: バックエンドでJWTトークンからuserIdを取得してユーザー情報を取得する

サーバーは以下の手順で処理します:

  1. JWTの検証:署名が正しく、有効期限内かをチェック
  2. userIdの取得:JWTのペイロードからuserIdを抽出
  3. ユーザー情報の取得:userIdを使ってデータベースからユーザー情報を取得
  4. レスポンス:ユーザー情報をクライアントに返す

この仕組みにより、サーバーはセッション情報を保持することなく、JWTだけでユーザーを識別できます。

トークンリフレッシュの自動取得フロー

アクセストークンの有効期限が切れた場合、リフレッシュトークンを使って自動で新しいアクセストークンを取得します。

自動リフレッシュの流れ

  1. API呼び出し:フロントエンドが期限切れのアクセストークンでAPI呼び出し
  2. 401エラー:サーバーが「Unauthorized」エラーを返す
  3. リフレッシュ要求:フロントエンドがリフレッシュトークンを使って新しいアクセストークンを要求
POST /api/auth/refresh
{
  "refresh_token": "eyJhbGciOiJIUzI1NiIs..."
}
  1. 新トークン発行:サーバーが新しいアクセストークンを発行
  2. API再実行:フロントエンドが新しいトークンで元のAPIを再実行

この流れにより、ユーザーが意識することなく認証状態が維持されます。

実際のコード例

理論だけでなく、実際のコードも見てみましょう。

バックエンド例(Node.js + Express)

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

const app = express();
const SECRET_KEY = process.env.JWT_SECRET; // 環境変数から取得

app.use(express.json());

// ログインエンドポイント
app.post('/api/auth/login', async (req, res) => {
  const { email, password } = req.body;
  
  try {
    // データベースからユーザー情報を取得
    const user = await findUserByEmail(email);
    if (!user || !await bcrypt.compare(password, user.password)) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    // JWTトークンを生成
    const accessToken = jwt.sign(
      { 
        userId: user.id, 
        email: user.email 
      },
      SECRET_KEY,
      { expiresIn: '1h' }
    );

    const refreshToken = jwt.sign(
      { userId: user.id },
      SECRET_KEY,
      { expiresIn: '7d' }
    );

    res.json({
      access_token: accessToken,
      refresh_token: refreshToken,
      token_type: 'Bearer',
      expires_in: 3600
    });
  } catch (error) {
    res.status(500).json({ error: 'Internal server error' });
  }
});

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

  if (!token) {
    return res.status(401).json({ error: 'Access token required' });
  }

  jwt.verify(token, SECRET_KEY, (err, decoded) => {
    if (err) {
      return res.status(403).json({ error: 'Invalid token' });
    }
    req.user = decoded; // JWTから取得したユーザー情報をreqに追加
    next();
  });
};

// 保護されたエンドポイント
app.get('/api/user/profile', authenticateToken, async (req, res) => {
  try {
    // req.userにはJWTからデコードされたユーザー情報が入っている
    const userId = req.user.userId;
    
    // データベースからユーザー詳細情報を取得
    const userProfile = await getUserProfile(userId);
    
    res.json(userProfile);
  } catch (error) {
    res.status(500).json({ error: 'Failed to fetch user profile' });
  }
});

// リフレッシュトークンエンドポイント
app.post('/api/auth/refresh', (req, res) => {
  const { refresh_token } = req.body;
  
  if (!refresh_token) {
    return res.status(401).json({ error: 'Refresh token required' });
  }
  
  jwt.verify(refresh_token, SECRET_KEY, (err, decoded) => {
    if (err) {
      return res.status(403).json({ error: 'Invalid refresh token' });
    }

    // 新しいアクセストークンを発行
    const newAccessToken = jwt.sign(
      { userId: decoded.userId },
      SECRET_KEY,
      { expiresIn: '1h' }
    );

    res.json({
      access_token: newAccessToken,
      token_type: 'Bearer',
      expires_in: 3600
    });
  });
});

フロントエンド例(JavaScript)

class AuthService {
  constructor() {
    this.baseURL = 'http://localhost:3000/api';
  }

  // ログイン
  async login(email, password) {
    try {
      const response = await fetch(`${this.baseURL}/auth/login`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ email, password })
      });

      if (!response.ok) {
        throw new Error('Login failed');
      }

      const data = await response.json();
      
      // JWTをローカルストレージに保存
      localStorage.setItem('access_token', data.access_token);
      localStorage.setItem('refresh_token', data.refresh_token);
      
      return data;
    } catch (error) {
      console.error('Login error:', error);
      throw error;
    }
  }

  // JWTを取得
  getAccessToken() {
    return localStorage.getItem('access_token');
  }

  // 認証が必要なAPIを呼び出す
  async fetchWithAuth(url, options = {}) {
    const token = this.getAccessToken();
    
    const config = {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${token}`,
        'Content-Type': 'application/json',
      },
    };

    let response = await fetch(url, config);

    // トークンが期限切れの場合、リフレッシュを試行
    if (response.status === 401) {
      console.log('Token expired, refreshing...');
      const refreshed = await this.refreshToken();
      if (refreshed) {
        // 新しいトークンで再実行
        config.headers['Authorization'] = `Bearer ${this.getAccessToken()}`;
        response = await fetch(url, config);
      } else {
        // リフレッシュ失敗時はログインページへリダイレクト
        window.location.href = '/login';
        return;
      }
    }

    return response;
  }

  // トークンをリフレッシュ
  async refreshToken() {
    const refreshToken = localStorage.getItem('refresh_token');
    if (!refreshToken) return false;

    try {
      const response = await fetch(`${this.baseURL}/auth/refresh`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ refresh_token: refreshToken })
      });

      if (!response.ok) return false;

      const data = await response.json();
      localStorage.setItem('access_token', data.access_token);
      return true;
    } catch (error) {
      console.error('Token refresh failed:', error);
      return false;
    }
  }

  // ユーザープロフィール取得
  async getUserProfile() {
    const response = await this.fetchWithAuth(`${this.baseURL}/user/profile`);
    
    if (!response.ok) {
      throw new Error('Failed to fetch user profile');
    }
    
    return response.json();
  }

  // ログアウト
  logout() {
    localStorage.removeItem('access_token');
    localStorage.removeItem('refresh_token');
    window.location.href = '/login';
  }
}

// 使用例
const authService = new AuthService();

// ログイン処理
document.getElementById('loginForm')?.addEventListener('submit', async (e) => {
  e.preventDefault();
  const email = document.getElementById('email').value;
  const password = document.getElementById('password').value;
  
  try {
    await authService.login(email, password);
    console.log('Login successful!');
    
    // ユーザープロフィールを取得
    const profile = await authService.getUserProfile();
    console.log('User profile:', profile);
    
    // ダッシュボードへリダイレクト
    window.location.href = '/dashboard';
  } catch (error) {
    console.error('Login failed:', error);
    alert('ログインに失敗しました');
  }
});

JWTのメリット・デメリット

メリット

ステートレス サーバー側でセッション情報を管理する必要がありません。

スケーラブル 複数のサーバー間でユーザー情報を共有しやすいです。

情報を含められる トークン内にユーザーの権限情報なども含められます。

クロスドメイン対応 異なるドメイン間でも認証情報を受け渡しできます。

デメリット

トークンサイズが大きい セッションIDと比べて情報量が多いため、通信量が増加します。

無効化が困難 一度発行したトークンを無効にするのが困難です。

セキュリティリスク 適切に保存・管理しないと盗まれるリスクがあります。

セキュリティ対策と注意点

1. 機密情報は含めない

jwt.ioの画像でも確認できるように、JWTの中身は誰でも見ることができます。

パスワードなどは絶対に含めないでください。

2. 適切な保存場所を選ぶ

上記のコード例ではlocalStorageを使用していますが、それぞれメリット・デメリットがあります:

localStorage

  • メリット: ページリロードしても残る
  • デメリット: XSS攻撃でJavaScriptから読み取られる可能性

httpOnly Cookie

  • メリット: JavaScriptから読み取れない
  • デメリット: CSRF攻撃に対策が必要、SameSite属性の設定が重要

メモリ

  • メリット: XSS攻撃に強い
  • デメリット: ページリロードで消える

本番環境では、httpOnly Cookieの使用を検討してください。

3. 適切な有効期限を設定

コード例のように、アクセストークンは短時間(1時間)、リフレッシュトークンは適切な期間(7日)に設定しましょう。

4. HTTPS必須

JWTは必ずHTTPS環境で使用してください。平文でのやり取りは絶対に避けましょう。

5. 秘密鍵の管理

// NG: ハードコーディングは危険
const SECRET_KEY = 'your-secret-key';

// OK: 環境変数から取得
const SECRET_KEY = process.env.JWT_SECRET;

本番環境では環境変数や専用のキー管理サービスを使用してください。

よくある質問

JWTとセッション認証、どちらを選ぶべき?

プロジェクトの要件によります:

  • JWT: マイクロサービス、SPA、モバイルアプリ
  • セッション: 従来のWebアプリケーション、シンプルな構成

JWTが盗まれた場合どうすれば?

以下の対策があります:

  1. 短い有効期限を設定
  2. リフレッシュトークンの仕組みを活用
  3. ブラックリスト機能の実装(推奨)

JWTの署名アルゴリズムは何を使うべき?

HS256(HMAC SHA-256)が一般的です。より高いセキュリティが必要な場合はRS256(RSA SHA-256)を検討してください。

まとめ

JWTは「ユーザー情報を安全に運ぶトークン」として理解すると分かりやすいです。

重要なポイント

  • JWTにはuserIdなどの情報を含められる(jwt.ioで実際に確認できる)
  • アクセストークン(短期)とリフレッシュトークン(長期)の2つを使い分ける
  • 自動リフレッシュの仕組みでユーザーの利便性とセキュリティを両立
  • API呼び出し時にAuthorizationヘッダーで送信する
  • 機密情報は含めず、適切にセキュリティ対策を行う

この記事で紹介したコード例をベースに、実際のプロジェクトでJWT認証を実装してみてください。

特にjwt.ioでのデコード画像を見ることで、JWTの「中身が見える」という特徴がより理解できるはずです。


参考リンク