2026年5月31日

2026年5月31日

WordPressでPII(個人情報)をGDPRに沿って扱う実装例:エクスポート・削除・保存期間・暗号化

WordPress で独自に個人情報(PII)を保存しているなら、GDPR 対応の中心は「どこに何を保存しているかを把握し、本人の求めに応じて取り出せて、消せて、不要になったら自動で消える」状態をコードで作ることです。この記事では、コアのプライバシーツール(個人データのエクスポート/消去)に自作データを登録する方法、保存期間を過ぎたデータの自動削除、IP アドレスの匿名化、保存時の暗号化を、そのまま試せるコード付きで解説します。

なお、この記事は実装面の解説で、法的助言ではありません。適用範囲や法的根拠の判断は、社内の法務担当や専門家と確認してください。日本国内向けのサイトでも、EU の利用者を対象にしていれば GDPR が関係しうること、また日本の個人情報保護法にも別途の義務があることは押さえておきましょう。

まず「どこにPIIがあるか」を棚卸しする

実装の前に、サイト内の個人情報の置き場所を一覧にします。ここを飛ばすと、エクスポートや削除の機能を作っても「消したはずのデータが別の場所に残っている」状態になります。

WordPress で個人情報が溜まりやすい場所は次のとおりです。

  • wp_users / wp_usermeta:会員のメールアドレス、表示名、プロフィール項目
  • wp_comments:コメント投稿者の名前・メール・URL・IP アドレス・ユーザーエージェント
  • お問い合わせフォームプラグインの送信履歴(プラグインによっては独自テーブルに保存)
  • EC・会員プラグインの注文情報や住所
  • 自作プラグインの独自テーブル、wp_options や wp_postmeta に入れた値
  • アクセスログ、エラーログ、セキュリティプラグインのログ(IP アドレスを含むことが多い)
  • バックアップファイル、ステージング環境の DB コピー

コアと主要プラグインの多くは、後述するプライバシーツールに自分のデータを登録済みです。問題になりやすいのは「自作のコード」と「ログ・バックアップ」です。棚卸しの結果は、プライバシーポリシーの記載内容とも一致させます。

GDPR の条文で実装に直結するのは、データ最小化と保存期間の制限(第5条)、本人のアクセス権(第15条)、消去権(第17条)、データポータビリティ(第20条)、設計段階からのデータ保護(第25条)、処理の安全性(第32条)あたりです。以降の実装は、この対応関係を意識して読むと整理しやすくなります。

コアのプライバシーツールの仕組み

WordPress 4.9.6 以降、管理画面の「ツール」メニューに個人データのエクスポートと消去の画面があります(画面の表記はバージョンや言語設定で多少異なります)。管理者が本人のメールアドレスで依頼を作成すると、本人に確認メールが届き、確認後に管理者がエクスポートや消去を実行する流れです。

この仕組みは拡張できるように作られていて、次の2つのフィルターに自分の処理を登録すると、同じ画面から自作データも扱えるようになります。

  • wp_privacy_personal_data_exporters:エクスポーターの登録
  • wp_privacy_personal_data_erasers:イレイサー(消去処理)の登録

どちらもメールアドレスを受け取り、ページ番号ごとに少しずつ処理する設計です。データが多くてもタイムアウトしにくいよう、1回の呼び出しで処理する件数を絞り、続きがあるかどうかを done で返します。

以下の例は、自作プラグインが {$wpdb->prefix}myplugin_inquiries という独自テーブルに、問い合わせ(email、name、phone、message、created_at)を保存している想定です。コードは独自プラグインか mu-plugin に置き、本番に入れる前にステージングで動作を確かめてください。

自作データをエクスポート対象に登録する

<?php
/**
 * Plugin Name: MyPlugin Privacy Integration
 */

add_filter( 'wp_privacy_personal_data_exporters', function ( $exporters ) {
	$exporters['myplugin-inquiries'] = array(
		'exporter_friendly_name' => __( 'お問い合わせ履歴', 'myplugin' ),
		'callback'               => 'myplugin_export_inquiries',
	);
	return $exporters;
} );

function myplugin_export_inquiries( $email_address, $page = 1 ) {
	global $wpdb;

	$per_page = 50;
	$page     = max( 1, (int) $page );
	$offset   = ( $page - 1 ) * $per_page;
	$table    = $wpdb->prefix . 'myplugin_inquiries';

	$rows = $wpdb->get_results(
		$wpdb->prepare(
			"SELECT id, name, phone, message, created_at FROM %i WHERE email = %s ORDER BY id ASC LIMIT %d OFFSET %d",
			$table,
			$email_address,
			$per_page,
			$offset
		)
	);

	$items = array();
	foreach ( $rows as $row ) {
		$items[] = array(
			'group_id'    => 'myplugin-inquiries',
			'group_label' => __( 'お問い合わせ履歴', 'myplugin' ),
			'item_id'     => 'inquiry-' . (int) $row->id,
			'data'        => array(
				array( 'name' => __( '氏名', 'myplugin' ), 'value' => $row->name ),
				array( 'name' => __( '電話番号', 'myplugin' ), 'value' => myplugin_decrypt( $row->phone ) ),
				array( 'name' => __( '内容', 'myplugin' ), 'value' => $row->message ),
				array( 'name' => __( '送信日時', 'myplugin' ), 'value' => $row->created_at ),
			),
		);
	}

	return array(
		'data' => $items,
		'done' => count( $rows ) < $per_page,
	);
}

ポイントは3つです。1つ目は、SQL に値を直接埋め込まず $wpdb->prepare() を使うことです。テーブル名に使っている %i(識別子)は WordPress 6.2 で追加されたプレースホルダーなので、それより古い環境ではテーブル名を固定文字列で書きます。2つ目は、done を正しく返すことです。常に false を返すと処理が終わらず、常に true だと2ページ目以降が出力されません。3つ目は、エクスポートの出力はコア側でエスケープされて HTML ファイルになりますが、値そのものは暗号化を解いた「本人が読める形」で渡すことです。

自作データを消去対象に登録する

<?php
add_filter( 'wp_privacy_personal_data_erasers', function ( $erasers ) {
	$erasers['myplugin-inquiries'] = array(
		'eraser_friendly_name' => __( 'お問い合わせ履歴', 'myplugin' ),
		'callback'             => 'myplugin_erase_inquiries',
	);
	return $erasers;
} );

function myplugin_erase_inquiries( $email_address, $page = 1 ) {
	global $wpdb;

	$table    = $wpdb->prefix . 'myplugin_inquiries';
	$per_page = 50;

	// 削除すると件数がずれるため、毎回先頭から取得する(OFFSET は使わない)
	$ids = $wpdb->get_col(
		$wpdb->prepare(
			"SELECT id FROM %i WHERE email = %s ORDER BY id ASC LIMIT %d",
			$table,
			$email_address,
			$per_page
		)
	);

	$removed = 0;
	foreach ( $ids as $id ) {
		$removed += (int) $wpdb->delete( $table, array( 'id' => (int) $id ), array( '%d' ) );
	}

	return array(
		'items_removed'  => $removed > 0,
		'items_retained' => false,
		'messages'       => array(),
		'done'           => count( $ids ) < $per_page,
	);
}

消去処理でつまずきやすいのは、エクスポートと同じ OFFSET 方式でページ送りしてしまうことです。削除した分だけ行が前に詰まるため、2ページ目で取りこぼしが起きます。上の例のように毎回先頭から取得するか、ID の範囲で区切ってください。

また、会計記録など法令で保存が求められるデータは消せない場合があります。その場合は削除せずに items_retained を true にし、messages に「法令上の保存義務があるため保持しています」といった理由を入れます。この理由は管理画面に表示され、本人への説明にも使えます。行全体を残す必要がある場合は、氏名や電話番号の列だけを wp_privacy_anonymize_data( 'text', $value ) などで置き換える方法もあります。

プライバシーポリシーに追記候補の文案を出す

自作プラグインがどんなデータを集めるかは、サイト管理者がプライバシーポリシーに書く必要があります。wp_add_privacy_policy_content() を使うと、プライバシーポリシー編集画面のガイドに文案を表示できます。

<?php
add_action( 'admin_init', function () {
	if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) {
		return;
	}
	$content = '<p>' . esc_html__( 'お問い合わせフォームでは、氏名・メールアドレス・電話番号・お問い合わせ内容を受け取り、回答のためにのみ利用します。送信データは1年後に自動で削除します。', 'myplugin' ) . '</p>';
	wp_add_privacy_policy_content( 'MyPlugin', wp_kses_post( $content ) );
} );

ここに書いた保存期間と、次の自動削除の設定値は一致させてください。文章とコードが食い違うと、それ自体が説明責任の問題になります。

保存期間を過ぎたデータを自動で削除する

データ最小化の考え方では、目的を果たしたデータは持ち続けないのが基本です。WP-Cron で1日1回、期限切れの行を消します。

<?php
const MYPLUGIN_RETENTION_DAYS = 365; // プライバシーポリシーの記載(1年)と一致させる

register_activation_hook( __FILE__, function () {
	if ( ! wp_next_scheduled( 'myplugin_purge_old_inquiries' ) ) {
		wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'myplugin_purge_old_inquiries' );
	}
} );

register_deactivation_hook( __FILE__, function () {
	wp_clear_scheduled_hook( 'myplugin_purge_old_inquiries' );
} );

add_action( 'myplugin_purge_old_inquiries', function () {
	global $wpdb;
	$table  = $wpdb->prefix . 'myplugin_inquiries';
	$cutoff = gmdate( 'Y-m-d H:i:s', time() - MYPLUGIN_RETENTION_DAYS * DAY_IN_SECONDS );

	$wpdb->query(
		$wpdb->prepare( "DELETE FROM %i WHERE created_at < %s LIMIT 1000", $table, $cutoff )
	);
} );

register_activation_hook() は通常のプラグインファイルでのみ動き、mu-plugin では発火しません。mu-plugin に置く場合は init で wp_next_scheduled() を確認して登録します。また WP-Cron はアクセスがあったときに動く仕組みなので、アクセスの少ないサイトではサーバーの cron から wp cron event run --due-now を定期実行する構成にしておくと、削除が遅れにくくなります。created_at は UTC で保存している前提です。

IPアドレスは匿名化して持つ

不正対策のために IP アドレスを記録したい場合でも、生の値を長期保存する必要があるかは検討の余地があります。WordPress には wp_privacy_anonymize_ip() があり、IPv4 なら末尾のオクテットを 0 にした値を返します。

<?php
$raw_ip    = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
$stored_ip = wp_privacy_anonymize_ip( $raw_ip ); // 例: 203.0.113.45 → 203.0.113.0

レート制限など「直近数分だけ生の IP が必要」な処理は、短い有効期限のトランジェントで扱い、長期保存する側には匿名化した値だけを入れる、と分けるのが現実的です。

保存時の暗号化は鍵の置き場所が肝心

電話番号や住所のように特に慎重に扱いたい列は、DB に入れる前に暗号化しておくと、DB ダンプが流出した場合の被害を小さくできます。PHP 7.2 以降に標準で入っている libsodium を使う例です。WordPress が推奨する PHP はこれより新しいバージョンです(2026年10月現在)。

<?php
// wp-config.php に定義(値は例。下のコマンドで生成した自分の鍵に置き換える)
// define( 'MYPLUGIN_ENC_KEY', 'your-base64-key' );
// 鍵の生成例: php -r "echo base64_encode(random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES)), PHP_EOL;"

function myplugin_get_key() {
	$key = defined( 'MYPLUGIN_ENC_KEY' ) ? base64_decode( MYPLUGIN_ENC_KEY, true ) : false;
	if ( false === $key || SODIUM_CRYPTO_SECRETBOX_KEYBYTES !== strlen( $key ) ) {
		return false;
	}
	return $key;
}

function myplugin_encrypt( $plain ) {
	$key = myplugin_get_key();
	if ( false === $key || '' === $plain ) {
		return '';
	}
	$nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
	return base64_encode( $nonce . sodium_crypto_secretbox( $plain, $nonce, $key ) );
}

function myplugin_decrypt( $stored ) {
	$key = myplugin_get_key();
	$raw = base64_decode( (string) $stored, true );
	if ( false === $key || false === $raw || strlen( $raw ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) {
		return '';
	}
	$nonce  = substr( $raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
	$cipher = substr( $raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES );
	$plain  = sodium_crypto_secretbox_open( $cipher, $nonce, $key );
	return false === $plain ? '' : $plain;
}

鍵を DB(wp_options など)に保存すると、DB と鍵が一緒に漏れて暗号化の意味がなくなります。鍵は wp-config.php の定数か、Web から見えない場所の環境変数に置き、Git には含めません。鍵を失うと復号できなくなるので、鍵のバックアップは DB バックアップとは別の安全な場所に保管します。暗号化した列は検索や並べ替えができなくなるため、メールアドレスのように検索キーとして使う列は暗号化の対象から外すか、別途ハッシュ値の列を持つ設計にします。

見落としやすい周辺の対策

コードの実装と並行して、次の点も確認しておきます。

  • ログに個人情報を出さない:error_log( print_r( $_POST, true ) ) のようなデバッグコードは、フォームの中身をそのままログに残します。本番では WP_DEBUG_LOG の出力先と保存期間も見直します。
  • 管理画面での表示はエスケープし、閲覧権限を絞る:問い合わせ一覧の画面は current_user_can( 'manage_options' ) などで確認し、出力には esc_html() を使います。
  • 外部サービスへの送信:メール配信や CRM、アクセス解析にデータを送る場合は、その事業者との契約(データ処理契約)とプライバシーポリシーへの記載が必要になります。
  • Cookie の同意:アクセス解析や広告タグは同意前に読み込まない設計にします。同意管理は Complianz や CookieYes など保守されているプラグインがありますが、機能の範囲や無料版で使える項目は変わるため、導入時に公式の説明を確認してください。
  • 漏えい時の手順:GDPR では、監督機関への通知を原則として把握から72時間以内に行うことが求められています。誰が判断し、どこに連絡するかを事前に決めておきます。
  • バックアップ:削除依頼に応じても、過去のバックアップにはデータが残ります。バックアップの保存期間を決め、期限を過ぎたものは削除する運用にしておきます。

動作を確かめる手順

ステージング環境で、テスト用のメールアドレスを使って一連の流れを試します。

  1. テスト用のメールアドレスでフォームから数件送信する(ページ送りを確かめるため、1ページの件数より多く入れる)。
  2. 「ツール」の個人データのエクスポート画面で依頼を作成し、確認メールの手順を経てエクスポートを実行する。ダウンロードしたファイルに「お問い合わせ履歴」のグループと全件が含まれているか確認する。
  3. 消去画面で同じメールアドレスの依頼を作成・実行し、独自テーブルから該当行が消えたことを DB で確認する。
  4. created_at を古い日付にした行を用意し、wp cron event run myplugin_purge_old_inquiries で自動削除が動くか確認する。

本番で同じ操作をする前には、DB のバックアップを取得してください。

まとめ

  • 最初にサイト内の個人情報の置き場所を棚卸しし、プライバシーポリシーの記載と一致させる。
  • 自作データは wp_privacy_personal_data_exporters と wp_privacy_personal_data_erasers に登録し、コアの画面からエクスポート・消去できるようにする。消去は OFFSET を使わず先頭から処理する。
  • 法令で残すデータは items_retained と messages で理由を示し、不要な列だけ匿名化する。
  • 保存期間は WP-Cron で自動削除し、ポリシーの記載と設定値をそろえる。IP は wp_privacy_anonymize_ip() で匿名化して保存する。
  • 慎重に扱う列は libsodium で暗号化し、鍵は DB と分けて wp-config.php の定数などに置く。
  • 法的な判断は専門家と確認し、実装はステージングでテストしてから本番へ反映する。

お気軽にご相談ください

お見積りへ お問い合わせへ
▲