セキュリティ 2026年6月7日

問い合わせフォームのPIIをSHA-256でハッシュ化して計測連携した設計

XECIN PIIプライバシー計測技術検証

広告LP にコンバージョン計測を組み込むプロジェクトで、「拡張コンバージョン」を導入することになった。

拡張コンバージョンとは、フォーム送信時にユーザーのメールアドレスをハッシュ化して Google に送ることで、Cookie 規制の強化に伴って落ちているコンバージョン計測の精度を補う仕組みだ。設定自体はさほど難しくないが、「どうハッシュ化するか」「何を送って何を送らないか」という設計判断に、思った以上に時間がかかった。

今回はその実装と判断の記録を残しておく。


最初に試したアプローチが問題だった

最初に試したのは、フォーム送信時にメールアドレスをそのまま dataLayer に push するという方法だった。GTM の公式ドキュメントにサンプルとして載っている書き方に近い。

// ❌ 最初のアプローチ(問題あり)
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
  event: 'form_submit',
  user_data: {
    email: formData.email, // 生メールアドレスをそのまま渡していた
  }
});

これで計測自体は動いた。ただ、少し立ち止まって考えると問題があった。

dataLayer はブラウザのメモリ上に残るし、開発者ツールで誰でも確認できる。GTM が発火するタイミングのログを追えば、入力されたメールアドレスが平文で見える状態になっている。GA4 のイベントログにも残る可能性がある。「計測のためだから仕方ない」で済ませるには、影響範囲が大きすぎると判断した。


正規化 → SHA-256 → 送信の一本道設計

生 PII をどこにも残さない設計に切り替えた。フォームの値を受け取ったその場でハッシュ化し、ハッシュ値だけを dataLayer に渡す。生のメールアドレスは一時変数にしか存在しない状態にする。

まず SHA-256 の実装。Web Crypto API がブラウザネイティブで使えるので、外部ライブラリは使わない方針にした。

// SHA-256 ハッシュ(Web Crypto API ベース)
async function sha256(message) {
  const msgBuffer = new TextEncoder().encode(message);
  const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}

次に、ハッシュ化前の正規化処理。これを怠るとハッシュ不一致が起きる(後述)。

// メールアドレスの正規化(ハッシュ前に必ず実行)
function normalizeEmail(email) {
  return email.trim().toLowerCase();
}

フォーム送信時の実装はこうなる。

// ✅ 改善後: ハッシュ値のみを dataLayer に渡す
const handleSubmit = async (formData) => {
  const normalized = normalizeEmail(formData.email);
  const hashed = await sha256(normalized);

  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push({
    event: 'form_submit',
    form_id: 'contact',
    user_data: {
      sha256_email_address: hashed,
      // 生メールアドレスはここには絶対に渡さない
    }
  });

  // フォーム送信処理(バックエンドへの POST など)
  await submitForm(formData);
};

このフローにすることで、dataLayer に生のメールアドレスが残る経路がなくなった。


正規化を怠るとハッシュが一致しない

これが一番ハマったポイントだった。

テスト中に「拡張コンバージョンのマッチング率がほぼゼロ」という状況が発生した。送っているはずなのに Google 側でまったく一致していない。

原因は正規化の不足だった。入力値の前後に空白が入っているケースや、大文字を含むケースを想定していなかった。

// 正規化なしの場合、同じメールアドレスでも異なるハッシュになる
" User@Example.com " → sha256 → abc123...  (こちらが送る値)
"user@example.com"   → sha256 → xyz456...  (Google が保持している値)
// → マッチしない

// 正規化後は一致する
"user@example.com"   → sha256 → xyz456...  ✅

Google はユーザーが登録したメールアドレスを正規化(小文字化・前後空白除去)した上でハッシュ化して保持している。こちらが送る値も同じルールで正規化しないと、同じメールアドレスなのに異なるハッシュ値になる。

ドキュメントには「SHA-256 で送れ」とあるだけで、正規化の必要性は少し読み込まないと見えてこない。運用を考えると、このあたりを先に把握しておくかどうかで、テスト工数がかなり変わると思っている。


送る値・送らない値の線引き

計測のためにどの値を dataLayer に渡し、どれを渡さないかの線引きも、明示的に決めておく必要があった。

パラメータ扱い理由
emailSHA-256 ハッシュに変換して送る拡張 CV に必要。生値は渡さない
utm_source / utm_medium / utm_campaignそのまま送る広告経路の計測に必要。PII を含まない
utm_term送らない(除外)検索キーワードは PII になりうる
tel / 氏名送らない計測に不要。最小化原則

utm_term の除外は少し迷ったところだ。「どんなキーワードで検索してきた人がコンバージョンしたか」は広告最適化に役立つ情報だが、組み合わせによっては個人を特定できる可能性がある。計測上の利点よりも収集しないことを選んだ。

// utm_term は dataLayer にも送信ログにも残さない
// (改善の余地あり: 除外リストをコード直書きではなく設定ファイルで管理するのが望ましい)
function collectSafeUtmParams() {
  const params = new URLSearchParams(window.location.search);
  const allowList = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content'];
  const result = {};
  allowList.forEach(key => {
    if (params.has(key)) result[key] = params.get(key);
  });
  return result;
  // utm_term, gclid 等は意図的に除外している
}

社内での説明と合意の取り方

「なぜメールアドレスのハッシュ値を Google に送るのか」という説明を、社内やクライアントに対してどうするかも考える必要があった。

技術的には「ハッシュ化しているから安全」と説明できるが、受け取る側には「そもそもなぜメールアドレスが必要なのか」という疑問がある。そこを飛ばして「安全です」だけ言っても納得感が薄い。

私が整理した説明の順序はこうだ。

  1. なぜ計測に使うのか — Cookie 規制の強化でコンバージョン計測の精度が落ちている。拡張コンバージョンはその代替手段として Google が提供している仕組みで、多くのサイトで採用されている
  2. 何を送っているのか — 生のメールアドレスではなく、元に戻せないハッシュ値のみ。Google も同じ方法でユーザーデータを保持しているため、マッチングにのみ使われる
  3. 何を送っていないのか — 氏名・電話番号・検索キーワード等は一切渡していない
  4. プライバシーポリシーへの記載 — 「計測目的でハッシュ化した情報を Google と共有する場合がある」という一文を追記する

この順序で話すと、「後から知った」という状況を避けやすい。

計測のために集めるデータを増やすのではなく、集めるデータを最小化した上でできる範囲で計測精度を上げる、という考え方を共通認識にしたかった。これが今回の設計の一番の軸だったと思っている。


おわりに

今思えば、最初に「dataLayer に生メールアドレスを入れる」を試みた段階で、もう少し立ち止まるべきだった。計測の実装は「動けば OK」で進めがちだが、PII 周りは一度設計した後で見直すのが意外と手間になる。

正規化によるハッシュ不一致の問題も、ドキュメントに明記されていないと自力でハマるしかない類のものだった。今後は類似の実装を行う際、正規化の確認を実装チェックリストに入れておくつもりだ。

utm_term の扱いについても、「除外して終わり」ではなく、PII 最小化の判断基準をもう少し体系化したいとは思っている。ただ正直なところ、サービスの特性や利用規約の内容によって変わる部分が大きく、汎用的なルールに落とし込むのはまだ難しい。引き続き現場の判断を積み上げていくしかないと考えている。