ちょっと前の話になりますが、GDPR/ePrivacy対応のため自社専用の軽量なCMP(同意管理ツール)を自作しました。一般的にはSaaSを入れるところですが、「なぜ作ったか」と「どう作ったか」について書きたいと思います。
まずCookie同意ですが、海外向けにサービスを展開しているサイトでは、避けて通れなくなってきています。EUのGDPRが代表格ですが、アメリカでも州ごとのプライバシー法が年々増えていて、「同意を取らずに計測・広告タグを動かす」ことが当たり前ではなくなってきました。
EUではGDPRとePrivacy指令により、同意を取る前にGA4やGoogle 広告のような非必須のCookieを置いたり、閲覧データを外部に送ったりしてはいけないことになっています。「閉じる」を同意扱いにするのも、同意するより撤回する方が面倒なのもNGです。
この規制はEU域外の事業者でも、EUにいる人に向けてサービスを提供しているか、EUにいる人の行動を追跡している場合は対象になります。英語版を用意して海外にもサービスを出している、リマーケティング広告を回している、といった日本のサイトは、EUに事業所がなくても当てはまり得ます。
Google側の要求も加わりました。EU圏の訪問者にGoogle広告の機能を使うにはConsent Mode v2への対応が必須で、同意状態を正しくGoogleに伝えないと計測やリマーケティングが機能しません。国内でも2023年の改正電気通信事業法で「外部送信規律」が始まり、どのサービスにどんな情報を送っているかの公表が求められるようになっています。
つまり、必要なのは次の3つを同時に満たす仕組みです。
- 訪問者の地域を判定し、EU等では同意まで一切タグを動かさない
- 同意状態をGoogle Consent Modeに正しく伝え、いつでも撤回できる導線を置く
- 外部送信先・Cookie の一覧(ポリシー)を公表し、実際の通信と一致させ続ける

なぜ既製の同意管理ツールを入れなかったか
理由は3つです。
- タグの競合。
外部の同意管理スクリプトがGTM・広告タグ・SPA遷移と競合して、同意済みユーザーのコンバージョンまで欠落する事象を避けたかった - 制御対象が固定できる。
弊社が使う計測・広告はほぼGoogle系(GTM / GA4 / Google Ads)。制御すべきタグが決まっているので、汎用ツールの複雑さが要らない - 自社の強みと合う。
フロントエンド実装とアクセシビリティは弊社の得意分野で、軽量で監査可能な仕組みを自分たちで作る方が、必要な機能だけで用意できる
基本方針:日本は通知、海外は初期拒否
地域ごとの動きはこう決めました。
訪問者 | 初期状態 | 画面 | Google タグ |
|---|---|---|---|
日本から | 許可 | バナーなし | 従来どおり起動 |
日本以外から | 拒否 | 画面下にバナー(すべて許可 / すべて拒否 / 詳細設定)。選択後はフローティングの設定ボタン | 同意後にのみ起動 |
判定不能 | 拒否 | 海外と同じ | 同意後にのみ起動 |
GPC 信号あり | 広告のみ拒否 | 広告が拒否されている理由を表示 | 広告用途は停止 |
日本は「外部送信規律」に沿って通知・公表を基本とし、バナーは出しません。海外はEUも米国もまとめて初期状態を拒否に。米国州法の多くはオプトアウト中心なので厳しすぎる面はありますが、運用を単純にするために安全側に統一しました。
判定不能を拒否にするのも同じ理屈です。迷ったら「タグを動かさない」方向に倒す。これが全体を貫く設計思想で、後で出てくる「読み込み失敗時の挙動」もすべてこの方向です。
撤回の導線は「同意と同じくらい簡単」に
GDPRは、同意の撤回が同意するのと同じくらい簡単であることを求めています。バナーで2クリックで同意できたのに、撤回はポリシーページの奥からしかできない、では釣り合いません。
設定ダイアログを開く「Cookie設定」ボタンは、地域を問わずポリシーページの「同意の変更・停止方法」の節に置きます。海外モードではそれに加えて、バナーで選択したあと画面左下にフローティングの設定ボタンを常設し、どのページからでも変更・撤回できるようにしました。日本モードではもともとバナーを出していないので、フローティングボタンも出さずポリシーページのボタンだけにしています。案件によっては「日本でも出す」「出さない」に切り替えられます。
撤回すると、台帳に載っているGoogle系のCookieを削除し、以後の送信を止めます。
構成:パーツは3つ
[Edge] Cloudflare Worker が request.cf.country を見て
<html data-lcmp-region="jp|overseas"> を HTML に注入
[HTML] <head> 先頭の同期スクリプト(loader.js)が
地域・保存済みの選択・GPC から gtag('consent','default') を確定
→ 日本 or 有効な同意あり → GTM を読む
→ それ以外 → GTM を読まず、バナー UI を遅延読み込み
[UI] 選択 → 検証・保存 → gtag('consent','update') → GTM を読み込む(何度呼ばれても挿入は1回だけ)
[GTM] 各タグ側の「追加の同意チェック」で最終ガード
- 地域判定 ― Edgeで国を判定してHTMLに印を付ける
- 同意スクリプト ― 印と保存済みの選択から、バナー表示とGTM起動を決める
- 台帳 ― 使う外部サービスとCookieの一覧。ポリシー表と、撤回時の削除対象の元データ
① 地域判定はEdgeでHTMLに印を付ける
国判定はCloudflareの request.cf.country で済むのですが、どこで使うかが設計の分かれ目でした。
ブラウザ側から判定APIを叩く方式(海外の大手ツールはこの形が多い)にすると、応答が返るまで地域が分からず、日本の訪問者も一瞬「拒否」の状態になります。そこでEdgeでHTMLを書き換えて<html>に地域属性を埋める方式にしました。HTMLRewriter で data-lcmp-region="jp" を付けるだけなので、スクリプトは同期的に地域を読めます。
// dist/_worker.js/index.js(サイト側の post-build で生成)
import inner from './astro-entry.js';
import { wrapWorker } from './lg-region-wrap.js';
export default wrapWorker(inner);
弊社サイトはAstro + @astrojs/cloudflareなのでハマりどころがありました。アダプターが_worker.jsを生成する構成ではfunctions/ディレクトリが無視され、さらに事前レンダリング済みページはAstroのmiddlewareも通りません。結局、ビルド後に生成された Worker のエントリをラッパーで包むという形で注入しています。
言語では判定しない
<html lang> や navigator.language で判定すれとも考えましたが、EU在住者が日本語ページを開いたとき jp と誤判定すると、同意なしでタグが動く=危険側の誤りになります。
サーバー側で判定を挟めない案件(AWSやレンタルサーバーで、DNSもCloudflareに向けられない構成)向けには、弊社の判定用エンドポイントにIPアドレスで問い合わせる方式を用意しました。一般的な同意管理ツールと同じ「ベンダー側の判定API」方式です。判定に使うだけでIPも国コードも保存せず、応答もjpかoverseasの2値だけにしています。それも使えない場合の最終手段として、端末のタイムゾーン(Asia/Tokyo なら日本)で推定するモードもあります。
どの方式で判定するかはサイトごとの設定で選びます。ただし、スクリプトは設定に関係なく、まず同期的に分かる情報を見ます。
- HTMLに埋め込まれた地域属性(Edge判定があれば必ずこれ)
- Cookieに書かれた地域(HTMLを書き換えられない構成で、サーバー側がCookieに判定結果を渡す場合)
- 1・2のどちらも無いときだけ、設定で選んだ方式(IP判定APIまたはタイムゾーン)で判定
- それでも取れなければ海外扱い**(**安全側)
属性とCookieを常に優先しているのは、後からEdge判定に移行したときに設定を変えるだけで済ませるためです。IP判定APIがタイムアウトしても海外扱いに倒れるだけで、同意なしでタグが動くことはありません。
② 同意前は「ブロック」ではなく「読まない」
GoogleのConsent Modeには、GTMを先に読んで「拒否」の信号だけ送るAdvancedと、同意までGTM本体を読まないBasicの2つがあります。今回はBasicです。同意前にGTMを読むのは同意スクリプトだけ、という構造にすると、いろいろなことが単純になります。
- スクリプトの読み込みに失敗しても、GTMを読む者がいないので非必須タグは起動しない
- GTMスニペットをHTMLに直接書かない。GTMのカスタムHTMLタグとして配る形態も採らない(同意前にGTMを読むことになる)
- GTM側でも各タグに「解析/広告の同意がないと発火しない」チェックを設定して二重に守る
「同意前は通信0件、Cookie0件」は E2Eテストでネットワークレベルで検証しています。
const FORBIDDEN_BEFORE_CONSENT = [
'**googletagmanager.com/**', '**google-analytics.com/**',
'**googleadservices.com/**', '**doubleclick.net/**', // …
];
test('T02: overseas first visit sends nothing', async ({ page }) => {
const hits: string[] = [];
await page.route('**/*', route => {
if (FORBIDDEN_BEFORE_CONSENT.some(p => matchGlob(p, route.request().url()))) hits.push(route.request().url());
return route.continue();
});
await page.goto('/');
await expect(page.getByRole('region', { name: /cookie/i })).toBeVisible();
expect(hits).toEqual([]); // 通信 0 件
expect(await page.context().cookies()).toEqual([]); // Cookie 0 件
});
撤回時は台帳に列挙した既知のCookieだけを削除します。ワイルドカードで消しに行くことはしません。「把握しているものを消す」ので、台帳のDomain / Pathが実態と違うと削除に失敗する。台帳が正しくないとテストが落ちるようにしておくのが、ポリシー表と実際の通信を一致させ続けるうえで効いています。
③ GTMの外にある埋め込み(YouTube・地図・Web フォント)
GTMを止めれば終わり、ではありませんでした。YouTubeやGoogle マップのiframe、セルフホストできないWebフォントはGTMを通らずページから直接読み込まれ、同意前にCookieを置きます。
これにはHTML側で属性を1か所書き換える方式にしました。
<iframe data-lcmp-src="<https://www.youtube-nocookie.com/embed/ID>" data-lcmp-service="youtube" width="560" height="315"></iframe>
同意前は、台帳の値から生成したプレースホルダー(「このコンテンツはYouTubeから読み込まれ、IPアドレスや閲覧情報が提供元に送信されます」+「今回だけ表示 / 常に表示」ボタン)を出し、許可後に本物を読み込みます。
DOMを監視して読み込み前に自動で書き換える「自動ブロック」は採用しませんでした。読み込みの取りこぼしと表示崩れが避けられず、海外製品でも動作保証なしの位置づけの機能です。iframe の属性を1か所書き換える手間はありますが、確実に動作します。
Webフォントも同じ仕組みで止められます。<link rel="stylesheet"> の href を data-lcmp-href に変えるだけで、同意前はフォントのCSSを読まず、許可後に読み込みます。ローダーは <head> で同期実行され、追加された要素を即時に処理するので、許可済みのときはパーサーが <link> を挿入した直後に href が戻り、フォントの読み込みタイミングは従来とほぼ変わりません。
<link rel="stylesheet" data-lcmp-href="<https://fonts.googleapis.com/css2?family=…>" data-lcmp-service="google-fonts">
ただし同意制にすると、海外からの初回訪問ではフォントが読まれずシステムフォントで表示されます。それが嫌ならサーバー上にフォントファイルを置くセルフホストが正解で、外部送信そのものが無くなるので論点ごと消えます。
貼るのは1行、8KB
前提として、計測・広告タグはGTM経由で動かしていること。GTMを使っていないサイトはそのままでは導入できません(タグを GTMに移す作業が先に必要です)。
導入側の作業は <head> 先頭に同期スクリプトを1本置くだけです。
<script src="<https://cdn.liberogic.jp/lcmp/><siteId>/loader.js"></script>
サイズは loader.js がgzipで約8KB。バナーとダイアログのUIは別チャンクで、必要になったときだけ遅延読み込みします。
またGTMのgtm.js の挿入は「LCPの後 + 数秒」というオプションを付けました。設定した場合、挿入前に離脱した訪問者は計測されないトレードオフにはなりますが、Lighthouseのスコアへの影響を抑えられます。
サイト別発行と管理画面
設定(台帳・GTM ID・文言・テーマ)は発行時にバンドルへ焼き込み、CDNから配信します。実行時に設定JSONを取りに行くことはしません。「初期化は同期・通信なし」と「外部からの任意設定注入を禁止」を両立させるためで、設定変更 = 再発行・再配信です。公開ごとに版が残るので、問題があれば直前の版に戻せます。
発行物には loader.js / ui.js のほか、貼るだけのスニペット、導入手順書、同意チェック設定済みのGTMコンテナテンプレート(インポート用 JSON)を同梱しています。導入先のGTMで「同意チェックなしのタグ」が残る事故を減らすためです。
この発行を、開発者でなくてもできるようにしたのが管理画面です。主な機能はこんなところです。
- 下書きと公開の分離。
台帳は各サイトの外部送信についての法的な表明なので、編集してすぐ反映にはしません。公開前に「公開中の設定との差分」を日本語の変更リストで見せ、台帳を変えたのにポリシー改定日が据え置きなら警告します。公開履歴から直前のバージョンへ1操作で戻せます。 - よく使うサービスのカタログ。
GA4・Google 広告・Clarity・YouTube・reCAPTCHAなど国内サイトで頻出のサービスを、名称・提供者・目的・Cookieの定型情報つきで台帳に追加できます。カテゴリは提案値で、最終判断は人がします。 - Cookie 自動スキャン。
対象ページを実際のブラウザで「同意前」と「すべて許可」の2状態で開き、置かれたCookieと通信先を台帳と突合します。同意前に発火しているものは「重大」、台帳に無いものは「注意」。所見からボタン1つで台帳に追加できます。 - ポリシー表の自動描画。
ポリシーページには <div data-lcmp-policy-table> を置くだけ。表は loader.js が焼き込み済みの台帳から描くので、ポリシー表と実際の制御対象がズレません。 - テーマ編集。
バナー・ダイアログ・フローティングボタンの配色を、実物のプレビューを見ながら変えられます。文字色は 4.5:1、ボタンの枠線など非テキストは 3:1 のコントラスト比をその場で計算し、足りなければ警告します。
まとめ
同意管理は「バナーを出せば終わり」ではなく、地域判定・タグ制御・ポリシー公表・撤回導線の4つが噛み合っていて初めて成立します。今回はそれを、外部スクリプト1本と台帳1つに集約しました。台帳を直せばバナーの説明もポリシー表も撤回時の削除対象も同時に変わり、ズレが起きる場所がありません。
広告を掲載して収益を得るサイト(AdSense など)はEU圏に広告を出すならGoogle認定のCMPが必須なので対象外となりますが、一般的なサイトに導入するには十分です。使うタグが決まっていて、同意まわりを自分たちで監査できる状態にしたいサイトにとっては、既製ツールを入れるより見通しの良い選択肢になると思います。
DTPからWebの世界へ飛び込み、気づけばマークアップもフロントエンドもディレクションもアクセシビリティもこなす"技の仙人"。リベロジック創業期からマルチに活躍し、今や社内の生き字引的存在。最近は「アクセシビリティ対応、もっとAIに頼れないかな?」と、プロンプトを駆使した効率化の探究にハマり中。技術も思考も、まだまだ進化中
二俣 歩
IAAP認定ウェブアクセシビリティスペシャリスト(WAS) / マークアップエンジニア / フロントエンドエンジニア / ウェブディレクター