Webアプリケーションを開発する際、CSRF(Cross-Site Request Forgery / クロスサイトリクエストフォージェリ)対策は必須のセキュリティ対策です。

CSRFとは、ユーザーが意図しないリクエストを攻撃者が強制的に実行させる攻撃手法です。
例えば:

  • ユーザーがログイン中に、攻撃者のサイトから悪意のあるフォームが送信される
  • パスワード変更、購入処理、退会処理などが勝手に実行される
  • SNSでの意図しない投稿が行われる

本記事では、PHPでCSRF対策を実装する方法を、実際の開発で遭遇したトラブルと解決策を交えながら徹底的に解説します。


    CSRF攻撃とは

    攻撃のシナリオ

    例: パスワード変更の脆弱性

    <!-- 攻撃者のサイト evil.com -->
    <form action="https://example.com/change-password" method="POST">
      <input type="hidden" name="new_password" value="hacked123">
    </form>
    <script>
      document.forms[0].submit(); // 自動送信
    </script>
    

    ユーザーが example.com にログイン中に evil.com を訪問すると、自動的にパスワード変更リクエストが送信されてしまいます。

    被害例

    • ✅ 不正な送金・購入
    • ✅ パスワードやメールアドレスの変更
    • ✅ アカウントの削除
    • ✅ SNSでのスパム投稿
    • ✅ 管理者権限の悪用

    CSRF対策の基本原理

    CSRFトークンによる防御

    CSRF対策の基本は、正規のフォームから送信されたことを証明するトークンを使用することです。

    【正常なフロー】
    1. サーバーがフォームページを生成時に、ランダムなトークンを生成
    2. トークンをセッションに保存 & フォームのhiddenフィールドに埋め込む
    3. フォーム送信時に、POSTされたトークンとセッションのトークンを照合
    4. 一致すれば正規のリクエスト、不一致なら攻撃とみなす
    

    なぜCSRFトークンで防げるのか?

    攻撃者は被害者のセッション内のトークンを知ることができないため、正しいトークンを含むリクエストを送信できません。


    PHPでのCSRFトークン実装

    基本的な実装パターン

    1. トークン生成関数

    <?php
    /**
     * CSRFトークンを生成してセッションに保存
     * 
     * @return string 生成されたトークン
     */
    function generate_csrf_token() {
        // セッションが開始されていない場合は開始
        if (session_status() === PHP_SESSION_NONE) {
            session_start();
        }
        
        // 既存のトークンがあればそれを返す(二重生成を防ぐ)
        if (isset($_SESSION['csrf_token'])) {
            return $_SESSION['csrf_token'];
        }
        
        // 新しいトークンを生成(64文字のランダム文字列)
        $token = bin2hex(random_bytes(32));
        
        // セッションに保存
        $_SESSION['csrf_token'] = $token;
        
        return $token;
    }
    

    ポイント:

    • random_bytes(32) でセキュアな乱数を生成(PHP 7以降)
    • 既存トークンがあれば再利用(ページ遷移で変わらないようにする)
    • セッションに保存して後で検証できるようにする

    2. トークン検証関数

    <?php
    /**
     * POSTされたCSRFトークンを検証
     * 
     * @param string $token POSTされたトークン
     * @return bool トークンが有効ならtrue
     */
    function verify_csrf_token($token) {
        // セッションが開始されていない場合は開始
        if (session_status() === PHP_SESSION_NONE) {
            session_start();
        }
        
        // セッションにトークンがない場合は無効
        if (!isset($_SESSION['csrf_token'])) {
            return false;
        }
        
        // タイミング攻撃を防ぐためhash_equals()を使用
        return hash_equals($_SESSION['csrf_token'], $token);
    }
    

    ポイント:

    • hash_equals() を使用してタイミング攻撃を防ぐ
    • ===== は処理時間から文字列の一部が推測される可能性がある

    3. フォームへの埋め込み

    <?php
    // トークンを生成
    $csrf_token = generate_csrf_token();
    ?>
    
    <form method="POST" action="submit.php">
        <!-- CSRFトークンをhiddenフィールドに埋め込む -->
        <input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrf_token, ENT_QUOTES, 'UTF-8'); ?>">
        
        <input type="text" name="username" required>
        <input type="password" name="password" required>
        <button type="submit">ログイン</button>
    </form>
    

    セキュリティ上の注意:

    • htmlspecialchars() でエスケープしてXSSを防ぐ
    • ENT_QUOTES' もエスケープ

    4. フォーム送信時の検証

    <?php
    // submit.php
    
    session_start();
    
    // POSTリクエストかチェック
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        
        // CSRFトークンが送信されているかチェック
        if (!isset($_POST['csrf_token'])) {
            die('CSRFトークンがありません');
        }
        
        // トークンを検証
        if (!verify_csrf_token($_POST['csrf_token'])) {
            die('CSRFトークンが無効です');
        }
        
        // ✅ トークンが有効 → 処理を続行
        $username = $_POST['username'] ?? '';
        $password = $_POST['password'] ?? '';
        
        // ログイン処理など...
        
    } else {
        // GETリクエストはエラー
        die('不正なリクエストです');
    }
    

    クラスベースの実装例

    よりメンテナンスしやすいクラスベースの実装:

    <?php
    /**
     * CSRF対策を管理するクラス
     */
    class CSRFProtection {
        
        /**
         * セッションキー
         */
        private const TOKEN_KEY = 'csrf_token';
        
        /**
         * トークンの長さ(バイト)
         */
        private const TOKEN_LENGTH = 32;
        
        /**
         * CSRFトークンを生成
         * 
         * @return string
         */
        public static function generateToken(): string {
            self::startSession();
            
            // 既存トークンがあれば返す
            if (isset($_SESSION[self::TOKEN_KEY])) {
                return $_SESSION[self::TOKEN_KEY];
            }
            
            // 新規トークンを生成
            $token = bin2hex(random_bytes(self::TOKEN_LENGTH));
            $_SESSION[self::TOKEN_KEY] = $token;
            
            return $token;
        }
        
        /**
         * CSRFトークンを検証
         * 
         * @param string $token POSTされたトークン
         * @return bool
         */
        public static function verifyToken(string $token): bool {
            self::startSession();
            
            if (!isset($_SESSION[self::TOKEN_KEY])) {
                return false;
            }
            
            return hash_equals($_SESSION[self::TOKEN_KEY], $token);
        }
        
        /**
         * hiddenフィールドのHTMLを生成
         * 
         * @return string
         */
        public static function generateField(): string {
            $token = self::generateToken();
            return sprintf(
                '<input type="hidden" name="csrf_token" value="%s">',
                htmlspecialchars($token, ENT_QUOTES, 'UTF-8')
            );
        }
        
        /**
         * セッションを開始(未開始の場合のみ)
         */
        private static function startSession(): void {
            if (session_status() === PHP_SESSION_NONE) {
                session_start();
            }
        }
    }
    

    使用例:

    <?php
    // フォーム表示
    ?>
    <form method="POST">
        <?php echo CSRFProtection::generateField(); ?>
        <input type="text" name="email">
        <button type="submit">送信</button>
    </form>
    
    <?php
    // フォーム処理
    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if (!CSRFProtection::verifyToken($_POST['csrf_token'] ?? '')) {
            die('CSRF検証エラー');
        }
        
        // 正常な処理...
    }
    

    よくあるエラーと解決策

    エラー1: トークンが常に不一致

    症状:

    CSRFトークンが無効です
    

    原因と解決策:

    原因1: 複数の関数でトークンを生成している

    // ❌ 問題のあるコード
    // index.php
    $token = generate_csrf_token();  // トークンA生成
    
    // confirm.php
    $token = generate_csrf_token();  // トークンB生成(別のトークン!)
    
    // submit.php
    verify_csrf_token($_POST['csrf_token']);  // トークンAと比較 → 不一致!
    

    ✅ 解決策: トークンは最初のページでのみ生成し、以降は既存トークンを再利用する

    function generate_csrf_token() {
        if (isset($_SESSION['csrf_token'])) {
            return $_SESSION['csrf_token'];  // 既存トークンを返す
        }
        
        $token = bin2hex(random_bytes(32));
        $_SESSION['csrf_token'] = $token;
        return $token;
    }
    

    原因2: 関数名の重複

    // ❌ 問題: 2つのファイルに同じ関数名
    // security-helper.php
    function generate_csrf_token() {
        return SecurityClass::generateToken();
    }
    
    // session-helper.php
    function generate_csrf_token() {  // ← 重複!
        return SessionClass::generateToken();
    }
    

    ✅ 解決策: 関数を一つのファイルに統一する

    // security-helper.php のみで定義
    function generate_csrf_token() {
        return SecurityClass::generateToken();
    }
    
    // session-helper.php からは削除
    

    原因3: セッションが開始されていない

    // ❌ session_start()を忘れている
    $token = bin2hex(random_bytes(32));
    $_SESSION['csrf_token'] = $token;  // エラー!
    

    ✅ 解決策: 必ずセッションを開始する

    if (session_status() === PHP_SESSION_NONE) {
        session_start();
    }
    $token = bin2hex(random_bytes(32));
    $_SESSION['csrf_token'] = $token;
    

    エラー2: フォーム送信後に「トークンがありません」

    症状:

    CSRFトークンがありません
    

    原因: hiddenフィールドが正しく出力されていない

    ✅ 解決策: フォームのHTMLを確認

    // ブラウザの開発者ツールで確認
    <input type="hidden" name="csrf_token" value="...actual token...">
    

    表示されていない場合:

    • PHPのエラーでトークン生成に失敗している
    • フォームのHTMLが間違っている

    エラー3: ページ遷移でトークンが変わる

    症状: 入力画面→確認画面→送信で、トークンが一致しない

    原因: 各ページでトークンを再生成している

    // ❌ 確認画面でトークンを再生成
    // confirm.php
    $token = bin2hex(random_bytes(32));  // 新しいトークン!
    $_SESSION['csrf_token'] = $token;
    

    ✅ 解決策: トークンはセッション開始時のみ生成

    // ✅ 既存トークンがあれば再利用
    function generate_csrf_token() {
        if (isset($_SESSION['csrf_token'])) {
            return $_SESSION['csrf_token'];  // 既存を返す
        }
        
        $token = bin2hex(random_bytes(32));
        $_SESSION['csrf_token'] = $token;
        return $token;
    }
    

    デバッグ方法

    デバッグ用の表示コード

    トークンの状態を可視化してデバッグします:

    <?php
    // デバッグ情報を表示(本番環境では削除)
    if (true) {  // デバッグモード
        echo '<div style="position: fixed; top: 10px; right: 10px; background: #fff3cd; border: 2px solid #ffc107; padding: 15px; border-radius: 8px; font-family: monospace; font-size: 12px; z-index: 9999;">';
        echo '<strong>🔍 CSRF Token Debug</strong><br><br>';
        
        // 生成されたトークン
        $generated_token = generate_csrf_token();
        echo '<strong>Generated Token:</strong><br>';
        echo htmlspecialchars($generated_token) . '<br><br>';
        
        // セッションのトークン
        echo '<strong>Session Token:</strong><br>';
        echo htmlspecialchars($_SESSION['csrf_token'] ?? '(not set)') . '<br><br>';
        
        // POSTされたトークン(送信時のみ)
        if (isset($_POST['csrf_token'])) {
            echo '<strong>Posted Token:</strong><br>';
            echo htmlspecialchars($_POST['csrf_token']) . '<br><br>';
        }
        
        // 一致チェック
        echo '<strong>Match:</strong> ';
        if (isset($_POST['csrf_token'])) {
            if (hash_equals($generated_token, $_POST['csrf_token'])) {
                echo '<span style="color: green;">✓ OK</span>';
            } else {
                echo '<span style="color: red;">✗ NG</span>';
            }
        } else {
            echo '(not submitted yet)';
        }
        
        echo '</div>';
    }
    ?>
    

    デバッグで確認すべきポイント

    1. 入力画面で確認

      • Generated Token と Session Token が同じ
      • トークンが空でないか
    2. 確認画面で確認

      • トークンが変わっていない
      • Posted Token が正しく渡されているか
    3. 送信時に確認

      • 3つのトークン(Generated, Session, Posted)がすべて同じ

    ベストプラクティス

    1. トークンの有効期限を設定

    <?php
    /**
     * トークンに有効期限を設定
     */
    class CSRFProtectionWithExpiry {
        
        private const TOKEN_KEY = 'csrf_token';
        private const EXPIRY_KEY = 'csrf_token_expiry';
        private const TOKEN_LIFETIME = 3600; // 1時間
        
        public static function generateToken(): string {
            self::startSession();
            
            // 既存トークンが有効期限内なら再利用
            if (isset($_SESSION[self::TOKEN_KEY], $_SESSION[self::EXPIRY_KEY])) {
                if (time() < $_SESSION[self::EXPIRY_KEY]) {
                    return $_SESSION[self::TOKEN_KEY];
                }
            }
            
            // 新規トークンを生成
            $token = bin2hex(random_bytes(32));
            $_SESSION[self::TOKEN_KEY] = $token;
            $_SESSION[self::EXPIRY_KEY] = time() + self::TOKEN_LIFETIME;
            
            return $token;
        }
        
        public static function verifyToken(string $token): bool {
            self::startSession();
            
            // トークンの存在確認
            if (!isset($_SESSION[self::TOKEN_KEY], $_SESSION[self::EXPIRY_KEY])) {
                return false;
            }
            
            // 有効期限チェック
            if (time() >= $_SESSION[self::EXPIRY_KEY]) {
                return false;
            }
            
            // トークン照合
            return hash_equals($_SESSION[self::TOKEN_KEY], $token);
        }
        
        private static function startSession(): void {
            if (session_status() === PHP_SESSION_NONE) {
                session_start();
            }
        }
    }
    

    2. APIでのCSRF対策

    RESTful APIでは、通常のCSRFトークン方式ではなく:

    • SameSite Cookie 属性を使用
    • Custom Header でトークンを送信
    • Bearer Token 認証を使用
    <?php
    // カスタムヘッダーでトークン送信
    if ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '' !== $_SESSION['csrf_token']) {
        http_response_code(403);
        echo json_encode(['error' => 'CSRF token mismatch']);
        exit;
    }
    

    3. SameSite Cookie属性の設定

    <?php
    // セッションCookieにSameSite属性を設定
    session_set_cookie_params([
        'lifetime' => 0,
        'path' => '/',
        'domain' => '',
        'secure' => true,      // HTTPS必須
        'httponly' => true,    // JavaScriptからアクセス不可
        'samesite' => 'Strict' // クロスサイトリクエストでCookieを送信しない
    ]);
    
    session_start();
    

    4. ログ記録とモニタリング

    <?php
    public static function verifyToken(string $token): bool {
        self::startSession();
        
        if (!isset($_SESSION[self::TOKEN_KEY])) {
            error_log('CSRF: Token not found in session');
            return false;
        }
        
        $valid = hash_equals($_SESSION[self::TOKEN_KEY], $token);
        
        if (!$valid) {
            error_log(sprintf(
                'CSRF: Token mismatch - Expected: %s, Got: %s, IP: %s',
                $_SESSION[self::TOKEN_KEY],
                $token,
                $_SERVER['REMOTE_ADDR']
            ));
        }
        
        return $valid;
    }
    

    セキュリティチェックリスト

    • すべてのフォームにCSRFトークンを実装
    • hash_equals() を使用してタイミング攻撃を防ぐ
    • セッションCookieに Secure, HttpOnly, SameSite 属性を設定
    • HTTPS を使用(HTTPではトークンが盗聴される)
    • トークンをGETパラメータに含めない
    • トークンをログに出力しない
    • エラーログでCSRF攻撃を監視

    まとめ

    CSRF対策の重要ポイント

    1. トークンの生成

      • セキュアな乱数を使用(random_bytes()
      • 既存トークンがあれば再利用
    2. トークンの検証

      • hash_equals() でタイミング攻撃を防ぐ
      • セッションのトークンと照合
    3. トークンの管理

      • セッションに保存
      • hiddenフィールドに埋め込む
      • 有効期限を設定(推奨)
    4. デバッグ

      • トークンの状態を可視化
      • ログで攻撃を監視

    さらに学ぶべきこと

    • OWASP CSRF Prevention Cheat Sheet
    • SameSite Cookie属性の詳細
    • Content Security Policy (CSP)
    • CORS (Cross-Origin Resource Sharing)

    参考リンク


    関連記事

    URLパラメータで配列を渡す3つの方法と、WAFで真っ白になる問題の解決策 Web開発でフィルター機能を実装する際、URLパラメータで複数の値を渡したいケースはよくあります。例:複数の物件IDで絞り込み検索https://example.com/properties?id=muromi,meinohama&status=availab...  続きを読む