10
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

「セッション認証とトークン認証の違いがわからない」
「JWTって何が入っているの?」
「JWTの実装方法を知りたい」

Web APIを開発していると必ず出会うJWT(JSON Web Token)。本記事では、JWTの仕組みから実装、セキュリティ上の注意点まで、なぜそうするのかを丁寧に解説します。

認証と認可の基礎

認証(Authentication)と認可(Authorization)の違い

よく混同される2つの概念を整理しましょう。

┌─────────────────────────────────────────────────────────────┐
│                認証(Authentication)                        │
│                                                              │
│  「あなたは誰ですか?」                                      │
│                                                              │
│  ・ログイン処理                                              │
│  ・ユーザー名 + パスワード                                   │
│  ・生体認証、2要素認証                                       │
│                                                              │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                認可(Authorization)                         │
│                                                              │
│  「あなたに何ができますか?」                                │
│                                                              │
│  ・権限チェック                                              │
│  ・管理者 vs 一般ユーザー                                    │
│  ・読み取り専用 vs 編集可能                                  │
│                                                              │
└─────────────────────────────────────────────────────────────┘

例で理解する

あなたがオフィスビルに入ろうとしている場面を想像してください:

  1. 認証: 受付で身分証明書を見せて「あなたが誰か」を確認する
  2. 認可: 入館証をもらい「どのフロアに入れるか」が決まる

Webアプリでは:

  1. 認証: ログインで「あなたが誰か」を確認
  2. 認可: リクエストごとに「その操作をする権限があるか」を確認

セッション認証 vs トークン認証

Webアプリで認証状態を維持する方法は主に2つあります。

セッション認証(ステートフル)

┌─────────────────────────────────────────────────────────────┐
│                   セッション認証の流れ                       │
│                                                              │
│  1. クライアント → サーバー : ログイン(ID/PW)              │
│                                                              │
│  2. サーバー: セッションを作成してメモリ/DBに保存            │
│     { sessionId: "abc123", userId: 1, expiresAt: ... }      │
│                                                              │
│  3. サーバー → クライアント : セッションIDをCookieで返す     │
│     Set-Cookie: sessionId=abc123                            │
│                                                              │
│  4. クライアント → サーバー : 以降のリクエストにCookieを付与 │
│     Cookie: sessionId=abc123                                │
│                                                              │
│  5. サーバー: セッションIDでセッションデータを検索           │
│                                                              │
└─────────────────────────────────────────────────────────────┘

トークン認証(ステートレス)

┌─────────────────────────────────────────────────────────────┐
│                   トークン認証の流れ                         │
│                                                              │
│  1. クライアント → サーバー : ログイン(ID/PW)              │
│                                                              │
│  2. サーバー: JWTを生成(サーバーに保存しない)              │
│     ユーザー情報と署名を含むトークン                         │
│                                                              │
│  3. サーバー → クライアント : JWTを返す                      │
│     { "token": "eyJhbGciOiJIUzI1NiIs..." }                  │
│                                                              │
│  4. クライアント → サーバー : Authorizationヘッダーで送信    │
│     Authorization: Bearer eyJhbGciOiJIUzI1NiIs...           │
│                                                              │
│  5. サーバー: トークンの署名を検証(DBアクセス不要)         │
│                                                              │
└─────────────────────────────────────────────────────────────┘

比較表

特徴 セッション認証 トークン認証(JWT)
サーバーの状態 ステートフル(セッション保持) ステートレス
スケーラビリティ △(セッション共有が必要) ◎(サーバー間で共有不要)
複数サーバー対応 Redis等で共有が必要 秘密鍵さえ共有すればOK
モバイル対応 △(Cookieの扱いが複雑) ◎(ヘッダーで簡単)
ログアウト 簡単(セッション削除) 難しい(後述)
トークンサイズ 小(IDのみ) 大(ペイロード含む)

なぜトークン認証が主流になったのか?

  1. マイクロサービス: 複数のサービス間でセッションを共有するのは複雑
  2. モバイルアプリ: Cookieよりトークンの方が扱いやすい
  3. スケーラビリティ: サーバーをステートレスに保てる
  4. クロスドメイン: 異なるドメインのAPIを呼び出しやすい

JWTとは

JWTの構造

JWT(JSON Web Token)は、3つの部分からなる文字列です。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlRhbmFrYSIsImlhdCI6MTUxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
└──────────────ヘッダー──────────────┘ └──────────────ペイロード──────────────┘ └──────────署名──────────┘

ドット(.)で区切られた3つの部分:

  1. ヘッダー(Header): トークンのメタ情報(アルゴリズムなど)
  2. ペイロード(Payload): 実際のデータ(ユーザーID、有効期限など)
  3. 署名(Signature): 改ざん防止のための署名

各部分の詳細

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

// ペイロード(Base64URLデコード後)
{
  "sub": "1234567890",        // Subject(ユーザーID)
  "name": "Tanaka",           // カスタムクレーム
  "iat": 1516239022,          // Issued At(発行日時)
  "exp": 1516242622           // Expiration(有効期限)
}

// 署名
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

署名の仕組み

なぜ署名が必要なのか?

ペイロードはBase64URLエンコードされているだけで、暗号化されていません。誰でもデコードして中身を読めます。

では、なぜ改ざんできないのか?それが「署名」の役割です。

┌─────────────────────────────────────────────────────────────┐
│                      署名の仕組み                            │
│                                                              │
│  発行時(サーバー側):                                       │
│                                                              │
│  ヘッダー + ペイロード + 秘密鍵 → ハッシュ関数 → 署名        │
│                                                              │
│  検証時(サーバー側):                                       │
│                                                              │
│  1. 受け取ったトークンの署名を分離                          │
│  2. ヘッダー + ペイロード + 秘密鍵 で再計算                  │
│  3. 再計算した署名と受け取った署名を比較                    │
│  4. 一致すれば「改ざんなし」                                │
│                                                              │
└─────────────────────────────────────────────────────────────┘

攻撃者がペイロードを改ざん(例:"admin": trueを追加)しても、秘密鍵を知らないと正しい署名を生成できません。サーバーで検証すると「署名が一致しない」となり、改ざんが検出されます。

クレーム(Claims)

ペイロードに含まれるデータを「クレーム」と呼びます。

予約済みクレーム(Registered Claims)

クレーム 正式名 説明
iss Issuer トークン発行者
sub Subject トークンの主体(ユーザーID)
aud Audience トークンの受信者
exp Expiration 有効期限(UNIX時間)
nbf Not Before 有効開始時刻
iat Issued At 発行時刻
jti JWT ID トークンの一意識別子

カスタムクレーム

アプリ固有のデータを追加できます:

{
  "sub": "user_123",
  "name": "田中太郎",
  "email": "tanaka@example.com",
  "role": "admin",           // 権限情報
  "permissions": ["read", "write", "delete"]  // 詳細な権限
}

注意: JWTはBase64エンコードされているだけなので、機密情報(パスワードなど)は絶対に入れないでください。

JWTの実装

Node.js + Express での実装

# 必要なパッケージをインストール
npm install express jsonwebtoken bcryptjs dotenv
// .env
JWT_SECRET=your-super-secret-key-change-this-in-production
JWT_EXPIRES_IN=1h
// app.js
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
require('dotenv').config();

const app = express();
app.use(express.json());

// 仮のユーザーデータベース
const users = [];

// ========================================
// ユーザー登録
// ========================================
app.post('/api/register', async (req, res) => {
    try {
        const { email, password, name } = req.body;
        
        // バリデーション
        if (!email || !password || !name) {
            return res.status(400).json({ error: '必須項目が不足しています' });
        }
        
        // 既存ユーザーチェック
        if (users.find(u => u.email === email)) {
            return res.status(409).json({ error: 'このメールアドレスは既に登録されています' });
        }
        
        // パスワードをハッシュ化
        // ソルトラウンド10は一般的な設定(セキュリティと速度のバランス)
        const hashedPassword = await bcrypt.hash(password, 10);
        
        // ユーザーを保存
        const user = {
            id: users.length + 1,
            email,
            password: hashedPassword,
            name,
            role: 'user'
        };
        users.push(user);
        
        // パスワードを除いたユーザー情報を返す
        const { password: _, ...userWithoutPassword } = user;
        res.status(201).json({ user: userWithoutPassword });
        
    } catch (error) {
        console.error('Registration error:', error);
        res.status(500).json({ error: 'サーバーエラーが発生しました' });
    }
});

// ========================================
// ログイン(JWTを発行)
// ========================================
app.post('/api/login', async (req, res) => {
    try {
        const { email, password } = req.body;
        
        // ユーザーを検索
        const user = users.find(u => u.email === email);
        if (!user) {
            // セキュリティ上、「ユーザーが存在しない」と「パスワードが違う」を
            // 区別して伝えない方が良い
            return res.status(401).json({ error: '認証に失敗しました' });
        }
        
        // パスワードを検証
        const isValidPassword = await bcrypt.compare(password, user.password);
        if (!isValidPassword) {
            return res.status(401).json({ error: '認証に失敗しました' });
        }
        
        // JWTを生成
        // ペイロードに入れるのは最小限の情報にする
        const payload = {
            sub: user.id,           // ユーザーID(Subject)
            email: user.email,
            name: user.name,
            role: user.role
        };
        
        const token = jwt.sign(payload, process.env.JWT_SECRET, {
            expiresIn: process.env.JWT_EXPIRES_IN,  // '1h'
            issuer: 'my-app',                        // 発行者
            audience: 'my-app-users'                 // 対象者
        });
        
        // トークンを返す
        res.json({
            message: 'ログインに成功しました',
            token,
            expiresIn: process.env.JWT_EXPIRES_IN
        });
        
    } catch (error) {
        console.error('Login error:', error);
        res.status(500).json({ error: 'サーバーエラーが発生しました' });
    }
});

// ========================================
// 認証ミドルウェア
// ========================================
const authenticateToken = (req, res, next) => {
    // Authorizationヘッダーからトークンを取得
    const authHeader = req.headers['authorization'];
    
    // Bearer <token> 形式
    const token = authHeader && authHeader.split(' ')[1];
    
    if (!token) {
        return res.status(401).json({ error: 'トークンがありません' });
    }
    
    try {
        // トークンを検証
        const decoded = jwt.verify(token, process.env.JWT_SECRET, {
            issuer: 'my-app',
            audience: 'my-app-users'
        });
        
        // リクエストオブジェクトにユーザー情報を追加
        req.user = decoded;
        next();
        
    } catch (error) {
        // エラーの種類に応じて適切なレスポンス
        if (error.name === 'TokenExpiredError') {
            return res.status(401).json({ error: 'トークンの有効期限が切れています' });
        }
        if (error.name === 'JsonWebTokenError') {
            return res.status(401).json({ error: '無効なトークンです' });
        }
        return res.status(401).json({ error: '認証に失敗しました' });
    }
};

// ========================================
// 認可ミドルウェア(ロールベース)
// ========================================
const requireRole = (...roles) => {
    return (req, res, next) => {
        if (!req.user) {
            return res.status(401).json({ error: '認証が必要です' });
        }
        
        if (!roles.includes(req.user.role)) {
            return res.status(403).json({ error: 'この操作を行う権限がありません' });
        }
        
        next();
    };
};

// ========================================
// 保護されたルート
// ========================================

// 認証が必要
app.get('/api/profile', authenticateToken, (req, res) => {
    // req.userにはJWTのペイロードが入っている
    res.json({
        message: 'プロフィール取得成功',
        user: req.user
    });
});

// 認証 + 管理者権限が必要
app.get('/api/admin/users', authenticateToken, requireRole('admin'), (req, res) => {
    // パスワードを除いたユーザー一覧を返す
    const usersWithoutPassword = users.map(({ password, ...user }) => user);
    res.json({ users: usersWithoutPassword });
});

app.listen(3000, () => {
    console.log('Server is running on port 3000');
});

クライアント側の実装

// フロントエンド(例:React)

// ========================================
// ログイン
// ========================================
async function login(email, password) {
    const response = await fetch('/api/login', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({ email, password }),
    });
    
    if (!response.ok) {
        const error = await response.json();
        throw new Error(error.error);
    }
    
    const data = await response.json();
    
    // トークンを保存
    // 注意: localStorageはXSS攻撃に弱い(後述)
    localStorage.setItem('token', data.token);
    
    return data;
}

// ========================================
// 認証済みリクエスト
// ========================================
async function fetchWithAuth(url, options = {}) {
    const token = localStorage.getItem('token');
    
    const response = await fetch(url, {
        ...options,
        headers: {
            ...options.headers,
            'Authorization': `Bearer ${token}`,
            'Content-Type': 'application/json',
        },
    });
    
    // トークン期限切れの場合
    if (response.status === 401) {
        // ログアウト処理
        localStorage.removeItem('token');
        window.location.href = '/login';
        throw new Error('セッションが切れました');
    }
    
    return response;
}

// ========================================
// プロフィール取得
// ========================================
async function getProfile() {
    const response = await fetchWithAuth('/api/profile');
    return response.json();
}

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

リフレッシュトークン

なぜリフレッシュトークンが必要か

JWTには有効期限があります。期限が切れたらユーザーは再ログインが必要です。

しかし、アクセストークンの有効期限を長くすると、セキュリティリスクが高まります(トークンが漏洩した場合の被害が大きい)。

解決策:アクセストークンとリフレッシュトークンの2つを使う

┌─────────────────────────────────────────────────────────────┐
│                 2種類のトークン                              │
│                                                              │
│  アクセストークン                                            │
│  ・有効期限:短い(15分〜1時間)                             │
│  ・用途:APIアクセスの認証                                   │
│  ・保存場所:メモリ or Cookie                                │
│                                                              │
│  リフレッシュトークン                                        │
│  ・有効期限:長い(7日〜30日)                               │
│  ・用途:新しいアクセストークンの取得                        │
│  ・保存場所:HttpOnly Cookie(XSS対策)                      │
│                                                              │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│                リフレッシュの流れ                            │
│                                                              │
│  1. ログイン → アクセストークン + リフレッシュトークン        │
│                                                              │
│  2. APIリクエスト時                                          │
│     → アクセストークンで認証                                 │
│                                                              │
│  3. アクセストークン期限切れ                                 │
│     → 401エラー                                              │
│                                                              │
│  4. リフレッシュトークンで新しいアクセストークンを取得        │
│     POST /api/refresh                                       │
│                                                              │
│  5. 新しいアクセストークンで再リクエスト                     │
│                                                              │
└─────────────────────────────────────────────────────────────┘

リフレッシュトークンの実装

// サーバー側

// リフレッシュトークンの保存(実際はDBに保存)
const refreshTokens = new Set();

app.post('/api/login', async (req, res) => {
    // ... 認証処理 ...
    
    // アクセストークン(短い有効期限)
    const accessToken = jwt.sign(payload, process.env.JWT_SECRET, {
        expiresIn: '15m'
    });
    
    // リフレッシュトークン(長い有効期限)
    const refreshToken = jwt.sign(
        { sub: user.id },
        process.env.REFRESH_TOKEN_SECRET,
        { expiresIn: '7d' }
    );
    
    // リフレッシュトークンを保存(DBに保存すべき)
    refreshTokens.add(refreshToken);
    
    // リフレッシュトークンはHttpOnly Cookieで返す
    res.cookie('refreshToken', refreshToken, {
        httpOnly: true,      // JavaScriptからアクセス不可
        secure: true,        // HTTPS必須
        sameSite: 'strict',  // CSRF対策
        maxAge: 7 * 24 * 60 * 60 * 1000  // 7日
    });
    
    res.json({ accessToken });
});

// トークンリフレッシュ
app.post('/api/refresh', (req, res) => {
    const refreshToken = req.cookies.refreshToken;
    
    if (!refreshToken) {
        return res.status(401).json({ error: 'リフレッシュトークンがありません' });
    }
    
    // トークンが有効か確認
    if (!refreshTokens.has(refreshToken)) {
        return res.status(401).json({ error: '無効なリフレッシュトークンです' });
    }
    
    try {
        const decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
        
        // ユーザー情報を取得して新しいアクセストークンを発行
        const user = users.find(u => u.id === decoded.sub);
        if (!user) {
            return res.status(401).json({ error: 'ユーザーが見つかりません' });
        }
        
        const payload = {
            sub: user.id,
            email: user.email,
            name: user.name,
            role: user.role
        };
        
        const accessToken = jwt.sign(payload, process.env.JWT_SECRET, {
            expiresIn: '15m'
        });
        
        res.json({ accessToken });
        
    } catch (error) {
        return res.status(401).json({ error: 'リフレッシュトークンが無効です' });
    }
});

// ログアウト
app.post('/api/logout', (req, res) => {
    const refreshToken = req.cookies.refreshToken;
    
    // リフレッシュトークンを無効化
    if (refreshToken) {
        refreshTokens.delete(refreshToken);
    }
    
    // Cookieを削除
    res.clearCookie('refreshToken');
    
    res.json({ message: 'ログアウトしました' });
});

セキュリティのベストプラクティス

トークンの保存場所

選択肢と特徴

保存場所 XSS CSRF 備考
localStorage ❌ 脆弱 ✅ 安全 JavaScriptからアクセス可能
sessionStorage ❌ 脆弱 ✅ 安全 タブを閉じると消える
Cookie ✅ 安全(HttpOnly) ❌ 脆弱 SameSite設定で軽減可能
メモリ ✅ 安全 ✅ 安全 リロードで消える

推奨パターン

// アクセストークン:メモリ(変数)に保存
let accessToken = null;

// リフレッシュトークン:HttpOnly Cookie
// サーバー側でSet-Cookie

// リロード時はリフレッシュトークンでアクセストークンを再取得

絶対に避けるべきこと

1. 秘密鍵をコードに書かない

// ❌ 絶対ダメ
const secret = 'my-secret-key';

// ✅ 環境変数から読み込む
const secret = process.env.JWT_SECRET;

2. ペイロードに機密情報を入れない

// ❌ 絶対ダメ
const payload = {
    userId: 1,
    password: 'raw-password',    // パスワードを入れない
    creditCard: '1234-5678-...'  // クレジットカード情報を入れない
};

// ✅ 最小限の情報だけ
const payload = {
    sub: userId,
    role: 'user'
};

3. alg: "none" を許可しない

JWTには「署名なし」を意味するalg: "none"があります。これを許可すると、攻撃者が署名なしのトークンを作成できてしまいます。

// ✅ アルゴリズムを明示的に指定
jwt.verify(token, secret, { algorithms: ['HS256'] });

4. 有効期限を適切に設定

// ❌ 長すぎる有効期限
jwt.sign(payload, secret, { expiresIn: '365d' });

// ✅ 適切な有効期限
jwt.sign(payload, secret, { expiresIn: '15m' });  // アクセストークン
jwt.sign(payload, secret, { expiresIn: '7d' });   // リフレッシュトークン

JWTのログアウト問題

JWTはステートレスなので、一度発行したトークンをサーバー側で「無効化」できません。

対策方法

  1. 短い有効期限: アクセストークンは15分程度に
  2. ブラックリスト: 無効化したトークンのJTI(ID)をRedisなどに保存
  3. トークンバージョン: ユーザーごとにバージョン番号を持ち、ログアウト時にインクリメント
// ブラックリスト方式
const blacklist = new Set();

function logout(token) {
    const decoded = jwt.decode(token);
    blacklist.add(decoded.jti);
}

function verifyToken(token) {
    const decoded = jwt.decode(token);
    if (blacklist.has(decoded.jti)) {
        throw new Error('Token is blacklisted');
    }
    return jwt.verify(token, secret);
}

よくある質問

Q: JWTは暗号化されている?

A: いいえ、暗号化されていません。

JWTは署名されているだけで、暗号化はされていません。誰でもペイロードをデコードして読めます。

署名は「改ざん防止」のためであり、「機密性」を提供するものではありません。

機密情報を含める必要がある場合は、JWE(JSON Web Encryption)を使います。

Q: JWTとセッションどちらを使うべき?

A: ユースケースによります。

ユースケース 推奨
シンプルなWebアプリ セッション
SPA + API JWT
マイクロサービス JWT
モバイルアプリ JWT
高セキュリティ要件 セッション(即時無効化可能)

Q: トークンの有効期限はどのくらいが良い?

A: 一般的な目安

  • アクセストークン: 15分〜1時間
  • リフレッシュトークン: 7日〜30日

セキュリティ要件が高いほど短くしますが、UXとのバランスを考慮してください。

まとめ

JWTは現代のWeb開発で広く使われる認証方式です。この記事で学んだ内容を整理すると:

  1. 認証と認可の違い: 「誰か」を確認するのが認証、「何ができるか」を確認するのが認可
  2. セッション vs トークン: ステートフル vs ステートレス
  3. JWTの構造: ヘッダー + ペイロード + 署名
  4. 署名の仕組み: 改ざん防止(暗号化ではない)
  5. リフレッシュトークン: セキュリティとUXの両立
  6. セキュリティ: 保存場所、有効期限、ブラックリスト

JWTを正しく理解し、適切に実装することで、安全で使いやすい認証システムを構築できます。特にセキュリティ面は慎重に、ベストプラクティスに従って実装しましょう。

10
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?