Skip to content
Charles Shao
Go back

深挖 IAB TCF 与 GPP:工程师必懂的同意协议

Updated:
–views

2023 年 6 月,法国数据保护机构 CNIL 对 Criteo 开出了一张高达 4000 万欧元 的罚单。其核心理由并非指控 Criteo 存在主观恶意,而是指出它无法证明被重定向的用户真正授予了同意。换句话说,对于任意一次具体的广告竞价,Criteo 都拿不出一条完整且可审计的证据链来证明:屏幕背后的那个用户确实说过「可以」。

这正是 IAB TCF(Transparency and Consent Framework)被设计出来所要解决的核心痛点。

在整个广告技术(AdTech)生态中,TCF 属于那种极为罕见的协议——它表面上看起来像是一个普通的传输格式,但其本质却是一份严谨的法律合同。具体而言,它将 GDPR 中那条抽象的法律义务(即「只有在具备合法性基础时才能处理个人数据」)巧妙地编码成了一串 Base64 字符。正因如此,上百家需求方与供给方(DSP & SSP)能够在 100 毫秒的极短时间内,通过 OpenRTB 协议将其在整个供应链中传递。在这个过程中,链路上的每一家公司都能独立判断:眼前这个用户,是否真正同意了我接下来要执行的数据处理操作。

本文是一份专为在线工程师准备的协议实操走查。我们将深入探讨:CMP(Consent Management Platform)在页面上具体做什么、一条 TC String 里面到底打包了哪些信息、它又是如何经由 OpenRTB 协议在链路中传递的。同时,文章还将详细解析 bidder 在服务端收到请求后必须执行的授权检查逻辑,以及 GPP(Global Privacy Platform)是如何作为全球合规信封将 TCF 等区域性协议包裹其中的。需要强调的是,这不是一篇空泛的法律科普,文中的每一个小节都精准落在了 bidder、ad server 或测量供应商必须落地实现的工程细节上。

范围说明:在之前的 App 广告 SDK 一文中,我曾将端上的同意管理与合规一笔带过,并承诺「协议基础另文展开」——本文正是那块缺失的拼图。对比前一种方案,在 RTA 架构中「fail-open 是一个正当的商业选择」;然而在 TCF 的世界里,fail-open 几乎永远是一桩正在酝酿的合规事故。下文的 §6.4 将会专门讲清这一关键对照。

本文是 隐私与合规 系列的第 2 篇(同意框架)。 全系列 3 篇:

  1. 后 Cookie 身份层
  2. 深挖 IAB TCF 与 GPP
  3. 地区广告政策与合规

一句话定位:TCF 把「有没有同意」编成一条可在 OpenRTB 里传递的 TC String,bidder 必须按 purpose 做授权检查,不能 fail-open。

TL;DR

怎么读这篇(按角色取所需):对于赶时间的工程师,建议直接阅读 §3(TC String 解剖)、§4.4 / §4.5(consent vs LI、publisher restrictions)、§6(端到端集成)以及 §8(生产坑),这些是全篇的主干。§4.1–§4.3(11 个 purpose / feature 全枚举)可以作为参考表,第一遍可跳读,用到时再回来查阅。至于 §7(GPP)和 §9(可观测性),完全可以在你的业务走向全球或监管机构再加一部州法时再读不迟。

Table of contents

Open Table of contents

1. TCF 为什么存在:从 GDPR 到一个传输协议

2018 年 5 月 GDPR 正式生效时,整个 AdTech 行业立刻发现自己陷入了处境尴尬的泥沼。

一次普通的广告曝光(Impression)往往会触及几十家公司——包括媒体(Publisher)、SSP、ad exchange、DSP、DMP、CDP,以及各类测量和验证供应商、CMP。在这条复杂的供应链中,任何一家都可能在某个环节「处理个人数据」。GDPR 明确要求,每一次数据处理行为都必须建立在一个坚实的合法性基础之上。而在广告商业化变现的真实场景里,真正用得上的合法性选项其实只有两个:同意(consent,对应 Art. 6(1)(a))和正当利益(legitimate interest,对应 Art. 6(1)(f))。

这一法律要求立刻在工程侧引发了三个棘手的挑战:

面对这些痛点,IAB 给出的终极答案就是 TCF。它引入了一份标准化的统一 vendor 注册表,外加一张按 vendor ID 精确索引的位图。通过这种机制,这 30 家公司得以在极短的时间内共享同一份序列化的同意状态。

整套框架稳稳地立在三块基石之上:

支柱是什么由谁维护
GVL(Global Vendor List)一个 JSON 文件,详细列出每一家 TCF 注册 vendor,各有唯一 ID,并声明它需要哪些 purpose / featureIAB Europe,按月版本化
TC String一个 base64url 编码的位域,精准编码了「这个用户授权了哪些 vendor 和 purpose」用户交互后由 CMP 生成
CMP(Consent Management Platform)一个运行在页面上的 SDK,负责弹出同意 UI、收集选择、生成并持久化 TC String,最后通过标准 API 暴露它媒体选定的第三方(如 OneTrust、Sourcepoint、Didomi、Cookiebot 等)或自建

在这个生态中,CMP 是生产者,TC String 是产物,GVL 是 schema,而每一个下游的 vendor 都是消费者。

2. 四个角色与数据流

TCF 数据流:用户与媒体网页 / App(GDPR 数据控制者)交互;媒体加载 CMP 前端 SDK;CMP 一边把 TC String 持久化到第一方 cookie / localStorage / App 存储,一边通过 window.__tcfapi 把 TCData 暴露给页面上所有 vendor SDK;TC String 随后经 OpenRTB 搭车到服务端 vendor(DSP / SSP / 测量),后者解码它并跑 vendor 与 purpose 的双重授权检查——是则行动,否则 fail-closed 跳过 / 仅上下文 生产者 → 产物 → 消费者的流向:CMP 产出 TC String,页面通过 __tcfapi 暴露它,每个下游 vendor 各自独立解码、各自判断自己是否被授权行动。最终判断永远是 vendor AND purpose——把这个 AND 搞错,是最常见的合规失败(见 §6.3)。

在 TCF 的运转逻辑中,主要涉及四个核心角色:

需要强调的是,媒体本身也是一个特殊的 vendor。在 TC String 里,有一个专门的段被称为 Publisher TC,里面装载的是用户对媒体自身的用途的同意状态(这完全独立于第三方 vendor 的同意)。然而在实际开发中,它往往是服务端代码最容易忽略其存在的那个段。

3. TC String:协议层的解剖

TC String 就是那个在整个链路中穿梭的传输格式。一条真实的 TC String 通常长这样:

CQi298AQi298AEsACCENCbFoAP_gA...AA.ILCJB_C7NbXFmybZ36...

各个段之间通过 . 进行拼接。关于哪些段是必需的、哪些是可选的,规则曾随着版本更迭发生过变化。截至 2025 年 4 月的 v2.3 规范更新,现行的硬性规则如下:

段必需?内容
Core String必需元数据 + purpose 的 consent / LI + vendor 的 consent / LI + publisher restrictions
Disclosed Vendorsv2.3 起必需CMP 实际向用户披露过的那组 vendor(此举彻底解决了 special-purpose-only vendor 长期以来的一处歧义)
Publisher TC可选媒体自身的 purpose 同意,包含任何自定义 purpose

TC String 段结构:三个盒子用一个字面量 '.' 字符连接——Core String(必需,装元数据、purpose 的 consent/LI、vendor 的 consent/LI、publisher restrictions),然后 Disclosed Vendors(v2.3 起必需,CMP 向用户披露过的那组 vendor),然后 Publisher TC(可选,媒体自身的 purpose 同意) TC String 是各段用字面量 . 拼接而成。Core 永远在;Disclosed Vendors 在 v2.3 变成强制;Publisher TC 可选,也是服务端代码最常忘记去读的那个段。

如果你的服务端代码编写于 2025 年 4 月之前,那么单段字符串(即只有 Core 段)仍然能够被解码,但在形式上已经不再完整。现代的 CMP 都会发出两段或三段的字符串。

3.1 VendorConsents:bitfield 与 range 的取舍

在整个规范中,工程上最有意思的细节莫过于 VendorConsents(以及与之对应的 VendorLegitimateInterests)的编码方式。该段内设有一个名为 IsRangeEncoding 的标志位,用于在两种截然不同的布局之间灵活切换:

究竟哪种方式更短,完全取决于被授权集合的稀疏程度:

场景更短的编码
用户点了「全部接受」 → 几乎每个 vendor ID = 1Bitfield(一长串 1)
用户点了「全部拒绝」 → 几乎每个 vendor ID = 0Bitfield(一长串 0)
用户只对少数几家 vendor opt-in → 高度稀疏Range——而且优势极大

在实际运行中,CMP 会把两种方式都计算一遍,然后果断取更小的那个。对于单个用户来说,这点字节差异或许可以忽略不计,但考虑到 cookie 存在硬性的体积上限(多数浏览器限制每条 cookie 为 4KB),而 TCF 字符串远不是唯一在争夺这点宝贵预算的数据,这种极致的压缩策略就显得尤为关键了。

3.2 解码一条真实的 TC String

下面展示的是一条真实的生产环境 TC String(包含 Core + Disclosed Vendors,共两段):

CQi298AQi298AEsACCENCbFoAP_gAEPgAATAK8pB_C7NbWFiybZ3aLtEcAhHV9BjbsQwAAaJA2ABTDqWoJwHw0EYFAzQlKIKGQIAqiTBIQIkCACABUCgIIAFgSBMQEyQoDBKIoAggBMBAGFYAExiGIJAEQCIAKIUNEEAmBwJocIUWEDwywAABhKRUAQxEAAAAIagCYFcBAMAC8IEdAgMKYIIjEDAEGQKAlkbpCABojQYQhEtVAkAFiTyONgHDkZDgXUhEvoYsAlxm4oCCwiQfwuzW1xZsiwV-C7RDAIV1fQA27EMAAGiQNgAUw6lqCMA9tBEgRIkBCmCBkAAKIkQSAAKAgIAARAoQCAFQEATABMEKAwSADAIIAzAUBgUABIQgiCQBEAiACiFDVBQIkMCKDCFlhAsEkAICYSgVAEERAAAAiCABmBTAABAAriBPQQCECCCIxAwBBACAIZG5QgBaB0GMIRjVSIABQkcjDAFyxAQ8F1IQDoGLGJZNqKggBAA.ILCJB_C7NbXFmybZ36LtEcAhXV9BjbsQwAAaJA2ABTDqWoJwH20EaFEzQlKYKGQIAqiTBIQIsCAiABUChIIAVgSBMQEyQoDBKIsAggDMBQGFYAExiGIJAEQCIAKIUNUFAmRwJocIWWEDwywAgJhKRUAQxEAAACIagGYFcBAMAC-IE9BgMaYIIjEDAEGQKAlkbtCAFonQYwhGtVIkAFiTyONgXLkZDwXUhEvoYsYl124qCAE

如果将其粘贴进官方解码器 iabgpp.com/#/decode,你会得到大致如下的解析结果:

字段值
version2
cmpId300(Sourcepoint)
cmpVersion / consentScreen / consentLanguage2 / 2 / EN
vendorListVersion155——你的服务端应当拉取 GVL v155 才能正确解读这条字符串,详见 §8.1
policyVersion5——v2.2 上线时是 4;随 GVL v3 一起升到了 5。务必动态读取它,切忌硬编码
isServiceSpecifictrue
publisherCountryCodeCY(塞浦路斯)
purposeConsents{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}——11 个 purpose 全部 opt-in
purposeLegitimateInterests{2, 7, 8, 9, 10, 11}——注意缺了 1、3、4、5、6:purpose 1 按定义只能走 consent;3/4/5/6 则是 v2.2 明确砍掉 LI 选项的「画像四件套」
specialFeatureOptins{1}——opt-in 了精确地理位置,但没有 opt-in 设备指纹
vendorConsents 数量1,401 家 vendor
vendorLegitimateInterests 数量1,412 家 vendor

这堪称一个教科书式的「全部接受」案例——高达 1,400 家 vendor 的同意率,强烈暗示着该 CMP 的「全部接受」按钮在视觉设计上压倒性地盖过了「全部拒绝」按钮。需要警惕的是,在 v2.2 规范之后,这种诱导性的 UI 设计本身就已经构成了一项严峻的合规风险(详见 §8)。

4. 目的、特征与合法性基础

根据现行的 IAB Europe TCF Policies, Appendix A,TCF v2.2/v2.3 严谨地定义了 11 个 Purpose、3 个 Special Purpose、3 个 Feature 以及 2 个 Special Feature。vendor 必须在 GVL 里如实声明自己需要哪些权限,而最终的决定权则通过 CMP 交还给用户。

4.1 11 个 Purpose(取自 Appendix A 原文)

ID官方名称一句话是什么
1Store and/or access information on a deviceCookie、localStorage、设备 ID。唯一允许的合法性基础:Consent。这是 Appendix A 里唯一明确写死法律依据的 purpose——它直接对应 ePrivacy Directive Art. 5(3)。
2Use limited data to select advertising非个性化(上下文)选广告,外加 frequency capping
3Create profiles for personalised advertising为广告定向建立逐用户画像
4Use profiles to select personalised advertising用画像去定向某个用户
5Create profiles to personalise content同 3,用于编辑内容
6Use profiles to select personalised content同 4,用于编辑内容
7Measure advertising performance归因、转化、可见性
8Measure content performance内容侧的同上
9Understand audiences through statistics or combinations of data from different sources受众测量、面板派生的人口统计
10Develop and improve services产品改进、ML 模型训练
11Use limited data to select contentv2.2 新增——purpose 2 在内容侧的上下文对应物

4.2 3 个 Special Purpose

需要特别说明的是,special purpose 只能在正当利益(LI)的框架下进行处理——用户无法对其予以拒绝。背后的逻辑非常现实:如果没有这些基础权限,平台要么彻底瘫痪,要么就会违反其他法律法规。

ID官方名称现实例子
1Ensure security, prevent and detect fraud, and fix errorsIVT 检测、反 bot、错误恢复
2Deliver and present advertising and content把广告字节从服务器搬到用户——用到 IP、UA、屏幕尺寸这些你压根跳不过的东西
3Save and communicate privacy choices元目的:持久化并传递 TCF 状态本身。CMP 和 vendor 用它来读写 TC String

4.3 Feature 与 Special Feature

Feature(3 个)——vendor 声明它们是追求某个 purpose 时所用的手段,没有单独的用户勾选项,但要披露:

ID官方名称
1Match and combine data from other data sources
2Link different devices
3Identify devices based on information transmitted automatically(经 IP / UA / TCP 信号的被动指纹)

Special Feature(2 个)——需要用户显式 opt-in,而不只是泛泛的同意:

ID官方名称
1Use precise geolocation data(精度低于 500m,或经纬度小数点后多于 2 位)
2Actively scan device characteristics for identification(主动指纹——字体、插件、屏幕分辨率)

Feature 3 与 Special Feature 2 之间的界限极其关键。前者代表「我们接收并使用自动到达的设备信息」,这仅仅需要披露即可。而后者则意味着「我们主动探测设备的可识别特征」,这必须获得用户的显式 opt-in。正因如此,如果你的产品涉及任何形式的指纹识别,你必须清醒地知道自己究竟站在哪一侧。因为在多数 CMP UI 中,special feature 通常被折叠进了二级菜单,导致其 opt-in 率惨不忍睹。可以断言,在欧盟试图把商业模式建立在 Special Feature 2 之上,基本是一条死胡同。

对于每一个 purpose,最多存在两种可能的合法性基础(注意,v2.2 已经强制关闭了好几个选项):

针对单个 purpose,正确的服务端判断逻辑应当如下:

对我需要的每个 purpose P:
  在 GVL 里查这个 vendor 为 P 声明的法律依据。
    声明 consent     → 检查 PurposesConsent[P]        == 1
    声明 LI          → 检查 PurposesLITransparency[P]  == 1
    声明 flexible    → 看默认值,再叠加任何
                       publisher restriction 的覆盖
    未声明           → fail

令人遗憾的是,这是 TCF 落地过程中被实现得最离谱的一环。常见的失败模式包括:

铁律只有一条:永远相信 vendor 的 GVL 声明,千万别相信你的直觉。

4.5 Publisher restrictions:覆盖机制

媒体拥有至高无上的权利,可以在逐 purpose 层面强行覆盖一个 vendor 所声明的法律依据。目前存在三种 restriction 类型:

类型含义
0vendor 完全不得使用此 purpose(硬禁)
1此 purpose 上 vendor 必须依赖 consent,哪怕它的 GVL 声明了 LI
2此 purpose 上 vendor 必须依赖 LI,哪怕它的 GVL 声明了 consent

这些 restriction 通常驻留在 Publisher TC 段里(在某些特定情况下也会出现在 Core 段里)。服务端的决策顺序至关重要:必须先应用 publisher restrictions,然后再去检查那个修正后的正确位。一旦跳过这一步,即使开发者自以为精通 TCF,生产代码依然会轻易脱离合规的安全区。

5. Scope:如今只剩一种合法模式

在历史上,规范曾通过 Core String 里的 IsServiceSpecific 位定义了两种 scope 模式:

Scope含义今日状态
Service-Specific(IsServiceSpecific = 1)TC String 只对当前媒体 / 媒体组有效,存在媒体自己的域名下唯一合法模式
Global(IsServiceSpecific = 0)经 consensu.org 共享域名在所有媒体间共享 TC String,配一套 Out-of-Band(OOB)披露机制2021 年 9 月 1 日起废弃

现行的技术规范对此表述得毫不含糊:“This field must always have the value of 1. When a Vendor encounters a TC String with IsServiceSpecific = 0 then it is considered invalid.”

正因如此,IsServiceSpecific != 1 绝不是一个需要向后兼容的历史遗留问题,而是标志着一条彻底无效的字符串。面对这种情况,必须果断 fail-closed,直接丢弃它。

那么 global scope 为什么会走向消亡?因为它深度依赖一个名为 consensu.org 的第三方 cookie。而随着 Safari ITP 的重拳出击,以及各大浏览器更大范围的第三方 cookie 弃用浪潮,早在 IAB 正式宣判它死刑之前,它在技术上就已经名存实亡了。

总结来说,服务端只认 IsServiceSpecific = 1。一旦见到 0,立刻将其作为无效字符串处理并 fail-closed——千万别把它当成需要费心兼容的旧格式。

6. 端到端集成:工程师的实操走查

6.1 客户端:__tcfapi 这套 CMP API

IAB 对 CMP 的 JavaScript 接口面进行了严格的标准化。任何一个标榜 TCF v2 合规的 CMP,都必须在全局暴露 window.__tcfapi:

window.__tcfapi('getTCData', 2, function(tcData, success) {
  if (!success || !tcData) return;
  if (tcData.cmpStatus !== 'loaded') return;
  if (tcData.eventStatus !== 'tcloaded' &&
      tcData.eventStatus !== 'useractioncomplete') return;

  // tcData.tcString 就是我们要发往后端的那个核心传输格式。
  // tcData.gdprApplies 明确告诉我们 GDPR 是否适用。
  loadAdSDK(tcData.tcString, tcData.gdprApplies);
});

同时,可以通过 addEventListener 实时订阅同意状态的变化——比如用户突然回到 CMP 二级菜单,把「全部接受」改成了「全部拒绝」。一旦收到 useractioncomplete 事件,广告 SDK 应当立刻停止任何刚刚失去合法性基础的数据采集行为:

window.__tcfapi('addEventListener', 2, function(tcData, success) {
  if (!success) return;
  if (tcData.eventStatus === 'useractioncomplete') {
    revalidatePermissions(tcData);
  }
});

TCData 对象上承载的核心字段如下:

字段含义
tcString完整 TC String
cmpId、cmpVersionCMP 标识
gdprAppliestrue / false / undefined
eventStatustcloaded | cmpuishown | useractioncomplete
cmpStatusstub | loading | loaded | error
listenerId供 removeEventListener 用
purpose.consents[1..11]逐 purpose 的 consent
purpose.legitimateInterests[1..11]逐 purpose 的 LI
vendor.consents[<vendorId>]你的 vendor ID 是否有 consent
vendor.legitimateInterests[<vendorId>]你的 vendor ID 是否有 LI
specialFeatureOptins[1..2]special feature 的 opt-in
publisher.restrictionspublisher 覆盖

CMP stub 陷阱:需要特别警惕的是,在完整的 CMP 加载完毕之前,__tcfapi 就已经存在了——因为每个 CMP 都被强制要求在 <head> 元素之前注入一个 stub。如果在 stub 状态下调用 getTCData,它只会返回 cmpStatus === 'stub' 以及一个毫无意义的 tcString。切记,永远别拿一个 stub 状态的响应去驱动任何实质性的数据采集决策。

6.2 OpenRTB:四个字段的世界

在 CMP 和后端服务之间,TC String 巧妙地搭乘在 OpenRTB 的扩展字段上。其标准位置如下:

OpenRTB 2.5(扩展字段):

{
  "id": "bid-request-id",
  "imp": [...],
  "user": {
    "ext": {
      "consent": "CPubcgAPubcgABABBENBgWAAAAAAAAAAAAAAAAEXkAAA.YAAAAAAAAAAA"
    }
  },
  "regs": {
    "ext": {
      "gdpr": 1
    }
  }
}

到了 OpenRTB 2.6,这些字段被正式提升为一等字段,并且重磅引入了 GPP:

{
  "user": {
    "consent": "CPubcgAPubcgABABBENBgWAAAAAAAAAAAAAAAAEXkAAA.YAAAAAAAAAAA"
  },
  "regs": {
    "gdpr": 1,
    "gpp": "DBABMA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA",
    "gpp_sid": [2]
  }
}

在真实的生产环境中,这两套格式你都得妥善处理——因为 SSP 的升级往往是异步的,你完全可能在同一个小时内同时遭遇 user.consent 和 user.ext.consent。一段防御式的提取代码如下:

function extractTcString(bidRequest: any): string | undefined {
  return (
    bidRequest?.user?.consent ??
    bidRequest?.user?.ext?.consent
  );
}

function extractGdprApplies(bidRequest: any): boolean {
  const v =
    bidRequest?.regs?.gdpr ??
    bidRequest?.regs?.ext?.gdpr;
  return v === 1 || v === '1';
}

6.3 服务端决策流程

当 bidder 收到请求后,其决策流水线大致遵循这样的路径:gdpr → tc_string →(可选 CMP)→ publisher restrictions → vendor → purpose →(可选 special feature):

服务端 TCF 决策流水线的流程图:第 1 步 gdprApplies == 1?——若为 0 / 缺失,跳过 TCF、套用其他辖区规则;若适用,第 2 步 TC String 在不在、能否解码?——若缺失或解码失败,fail-closed 跳过该出价(解码失败是事故,要告警);若有效,第 4 步应用 publisher restrictions(禁用 / 强制 consent / 强制 LI);第 5 步 vendor ID 在 vendorConsents 或 LI 里吗?——若否,跳过(vendor 未授权);若是,第 6 步每个所需 purpose 按其 GVL 声明的依据都 OK 吗?——若任一失败,降级为仅上下文(purpose 1、2);若全过,第 7 步你是否需要某个 special feature,如精确地理位置或指纹?——需要但没 opt-in 则跳过该能力;否则带全量个性化继续 bidder 收到请求时的流水线。红色节点是硬停,琥珀色节点是降级为上下文,绿色是唯一一条全量个性化的出口。可选的第 3 步 CMP-ID 检查为清晰起见省略了(见下面细节 #2)。注意顺序:publisher restrictions 在 vendor/purpose 之前——restriction 能翻转一个 vendor 声明的法律依据,所以先检查那个位,就是在检查错的位。

具体所需的 purpose 集合,完全取决于这家 vendor 实际在执行什么业务。一个典型 DSP 的最低配置如下:

操作所需 purpose
接收 bid request、上下文出价1, 2
用第一方数据 / look-alike / 重定向1, 2, 3, 4
归因与效果测量1, 7
跨设备关联Feature 2 + 上述
用精确地理位置Special Feature 1 + 上述

四个安静地搞坏实现的细节

  1. vendor 检查放在 purpose 之前。从逻辑上讲,这个检查是一个 AND 操作——顺序并不会改变最终的正确性。但在实际运行中,vendor 优先显然更便宜:它只是一次简单的位查找,你的 vendor ID 是个常量,一旦不匹配就能迅速短路掉整条请求。相比之下,purpose 检查则需要对着 GVL 声明走一遍数组,单请求的计算成本更高。因此,几乎所有生产代码采用的都是 vendor.check() && purposes.check()。

  2. CMP 检查确实是可选的。你完全可以拿 cmpId 去比对 IAB CMP List,以确认字符串确实来自一个活跃且注册过的 CMP。但在实践中,几乎没人在热路径上这么干——因为 IAB MO 会在中心侧统一执行吊销操作,而给每条 bid request 强加一次 CMP-list 查找所带来的延迟,是你绝对不想承受的。只有当你观察到来自特定 CMP ID 的异常流量,想要做防御式筛查时,才需要加上它。

  3. publisher restrictions 必须跑在 vendor/purpose 之前,而不是之后。这正是多数服务端容易搞错的那条铁律。publisher restrictions 的本质是覆盖——它拥有翻转一个 vendor 在 GVL 里声明的法律依据的权力。如果某 vendor 把 purpose 2 声明在 LI 下,而媒体却把它限制为 consent(类型 1),此时如果你直接去检查 LI 位,那就是在检查一个错误的位。正确的做法是:先算出「这个媒体 × 这个 vendor × 这个 purpose 的有效法律依据」,然后再去检查对应的位。

  4. special feature 有它自己的独立检查,而不是混在默认的检查里。精确地理位置和主动指纹明确驻留在 SpecialFeatureOptIns 里,与常规的 purpose 位彻底分开。只有在你真的需要使用那份敏感数据时,才去检查它们。千万别把这两个 special-feature 检查粗暴地塞进默认的 purpose 检查循环中——对于 99% 根本用不到它们的出价路径来说,那纯粹是在白白浪费宝贵的 CPU 资源。

最常见的那个合规失败案例往往是这样的:服务端代码严谨地检查了 vendor consent,却偏偏遗漏了 purpose consent。结果是 vendor 确实被授权了,但用户从没同意过「建立画像」;然而 DSP 却照样跑起了重定向。必须明确,这是一次彻头彻尾的 GDPR 违规,绝不是什么可以糊弄过去的边角情况。

6.4 兜底策略:几乎永远 fail-closed

当 TC String 缺失、解码失败,或者解码出一个未授权状态时——你究竟该怎么办?在 RTA 的语境里,这可能只是个商业策略问题,有两个站得住脚的答案。但在 TCF 的世界里,答案几乎是唯一的。

场景推荐动作
gdprApplies == 0 或缺失当作非 GDPR 流量(信媒体的信号)
gdprApplies == 1、TC String 缺失fail-closed:跳过出价,或跑一条仅上下文的兜底(purpose 1、2)
TC String 在但解码失败fail-closed:这是事故级——立刻告警并记日志
解码成功但 vendor consent = 0不出价
解码成功但 purpose consent 不全降级为仅上下文

和 RTA 里 fail-open 是个正当商业选择截然不同,TCF 基本没有任何站得住脚的 fail-open 场景。在这里,fail-open 除了极少数例外,无一例外都是一桩正在酝酿的合规违规。

为什么 fail-closed 是法律 + 协议 + 系统设计,而不是「最佳实践」

在 AdTech 圈子里,fail-closed 经常被误认为是一个保守的惯例。事实并非如此。它被三个相互独立的方向各自强硬要求,任何一个理由都足够充分:

  1. 法律层:问责原则(Accountability Principle)。GDPR Art. 5(2) 明确规定:“The controller shall be responsible for, and be able to demonstrate compliance with”——每个处理数据的实体都必须能够证明自己合规。「我的上游没拒我」从来不是一个可被法庭采纳的抗辩理由。Art. 26(共同控制者——连带责任)和 Art. 28(处理者义务)更是彻底堵死了另一条明显的逃路:合规义务绝对不可转嫁。你根本没法把它外包给那个把请求发给你的 SSP。

  2. 协议层:IAB 把 fail-closed 编成了一条强制规则。TCF Policy §12(4) 指出:

If a Vendor is unable to read or process the contents of a received Signal, the Vendor must assume that it does not have permission to store and/or access information on a device, or to process personal data for any Purpose and/or Special Purpose.

简而言之,一个缺失的信号,按规定就是「否」。这绝不是一条软性建议。§12(3) 进一步禁止在实时决策里依赖缓存的旧的同意;§12(5) 则让「无法合规」在技术上直接等价于「不要行动」。把整个 §12 通读一遍,你会发现它就是 GDPR 问责原则翻译成 vendor 具体技术义务的完美镜像。

  1. 系统层:SSP 是一个不可信的中间方。用分布式系统的话来讲,SSP 和你的 DSP 分处一道信任边界(trust boundary)的两侧。SSP 把请求转发给你,并不意味着同意状态在上游已经被严谨核验过。这就是 defense in depth、don’t trust your inputs、fail-safe defaults、zero trust 的核心要义——在网络侧它完美对应端到端原则(end-to-end principle):端到端的语义检查必须由端点亲自来做,中间方的工作根本不算数。把上游当成一个可靠的过滤器,是经典的反模式。

综合来看:上游转发了,并不等于你被授权去处理。SSP 把决策权交给你,这并不是 bug,而是 GDPR 叠加 TCF 再叠加可靠系统设计共同要求的必然结果。而你唯一被允许把它交回去的方向,就是调用 skip_bid()。

同样的原则,也广泛出现在任何真正将责任压在端点上的领域:

7. GPP:把 TCF 包进更大的信封

读到 §6 结束,你已经具备了正确处理 GDPR 流量的能力。但必须清醒地认识到,TCF 仅仅只是一个法域。一家志在全球的媒体,至少要面对以下错综复杂的局面:

如果一家全球 DSP 试图为每个法域都单独开发一套独立的传输机制,那它大概率会面临灾难性的开发与维护成本。正是为了阻止这种碎片化,IAB 在 2022 年果断推出了 GPP(Global Privacy Platform)。

7.1 GPP 的设计:分节的信封

需要明确的是,GPP 本身并不是一个新的同意协议,而是一个极其巧妙的信封格式——它将多个法域的同意字符串无缝拼接起来,并在最前面加上一个描述里面装了什么的头部。

GPP 信封结构:一个 GPP Header(如 DBABMA,其首字符 D 标记它是 GPP)用 &#x27;&#x27; 连到 Section 2(TCF EU v2,一条原始 TCF 字符串以 C 开头),再用 &#x27;&#x27; 连到 Section 6(US Privacy v1);另有一个 gpp_sid = [2, 6] 节点指向这两个 section,作为信封内含物的索引 GPP 是个信封,不是新的同意协议:各 section 用 ~ 连接,gpp_sid 是告诉你哪些 section 在场的索引。注意首字符的提示——GPP 字符串是 D,原始 TCF 字符串是 C。

这个单字符的区别在日常排障中非常实用——在任何生产日志里,只需瞄一眼首字符,你就能立刻判断出自己正在看哪个协议。IAB 是故意把它设计成兼容性标记的:base64 的 2 编码成 C,而 3 则编码成 D。

每个 section 都拥有一个对应法域的数字 ID。下面列出前十个——ID 11–27 则沿着不断增长的美国州法名单继续往下延伸(犹他、康涅狄格、佛州、蒙大拿、俄勒冈、得州等),完整的列表可以在 GPP Section Information 文档 里找到:

IDAPI 前缀描述
1tcfeuv1EU TCF v1(已废弃)
2tcfeuv2EU TCF v2——§3 讲的就是它
3—GPP Header(必需)
4—GPP signal integrity
5tcfcav1加拿大 TCF
6uspv1US Privacy(旧 CCPA 格式)
7usnatMSPA US National
8uscaCalifornia(CPRA)
9usvaVirginia
10uscoColorado
……美国州法 section 一直延续到 ID 27(罗德岛)

除了独立的 section 之外,GPP 还支持使用 . 追加可复用的 subsection。在实际业务中,最重要的一项当属 Global Privacy Control(GPC)——这是一个浏览器自身可通过 Sec-GPC HTTP 头或 navigator.globalPrivacyControl 发出的 W3C 草案信号。CMP 会自动将其桥接进 GPP 字符串。这意味着,当一个用户在浏览器里拨动了「do not sell」开关时,他实际上正在向每一个 CMP 传递这个坚定的选择——他再也不必在每家媒体上分别去点「拒绝」。目前,美国各州的监管者(尤其是加州)已经开始将 GPC 视为一个具有法律约束力的 opt-out 信号,这使得它在实际运营中的地位变得举足轻重。

7.2 gpp_sid:告诉你信封里装了什么的索引

gpp_sid 本质上是一个数组,它清晰地枚举了这条 gpp 字符串里当前在场的 section ID:

{
  "regs": {
    "gpp": "DBABMA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA~1YNY",
    "gpp_sid": [2, 6]
  }
}

在这个例子中,信封同时携带了 TCF EU v2(对应 section 2)和 US Privacy v1(对应 section 6)。对于跨法域用户——比如 VPN 用户、国际旅行者,或者是双居所媒体——他们会频繁产出这种「叠加」状态的信封。

处理顺序应当如下:

1. 若 regs.gpp 存在且 gpp_sid 非空:
     解析 gpp_sid 列出的每个 section。
     找到与本次请求相关的 section(EU 用户找 section 2)。
     套用对应规则。
2. 否则回退到旧字段:
     regs.gdpr + user.consent(仅 TCF),或
     us_privacy 头(仅 CCPA)。

7.3 GPP 与 TCF 并存

一个极其常见的困惑是:GPP 是不是已经取代了 TCF?答案是否定的。

TCF 依然是 EU/UK 的核心法域规则。GPP 扮演的仅仅是一个传输与编码层,它的 EU section 里装着的正是一条 TCF v2 字符串。在生产环境中,你会频繁见到以下三种组合形态:

阶段bid request 里出现什么
Legacy(GPP 之前)只有 regs.ext.gdpr + user.ext.consent(或 OpenRTB 2.6 的一等字段版本)
过渡期TCF 字段和 regs.gpp + gpp_sid 都在,内容相同
GPP-only(较新的 SSP)只有 regs.gpp + gpp_sid;TCF 字段被完全省略——你必须从 GPP 信封里抽出 TC String

为了确保万无一失,生产代码必须将这三种情况全部覆盖:

function extractTcfTcString(bidRequest: any): string | undefined {
  const gpp = bidRequest?.regs?.gpp;
  const gppSid: number[] | undefined = bidRequest?.regs?.gpp_sid;

  if (gpp && gppSid?.includes(2)) {
    return extractSectionFromGpp(gpp, 2);
  }

  return bidRequest?.user?.consent ?? bidRequest?.user?.ext?.consent;
}

7.4 客户端:window.__gpp

类比于 __tcfapi,IAB 也定义了 window.__gpp 接口:

window.__gpp('getGPPData', function(gppData, success) {
  // gppData.gppString          — 完整 GPP 字符串
  // gppData.applicableSections — 如 [2, 8]
  // gppData.parsedSections     — 各 section 解析后的对象
});

window.__gpp('addEventListener', function(data, success) {
  // 实时订阅同意变化
});

目前的主流 CMP——如 OneTrust、Sourcepoint、Didomi、Usercentrics——都已经同时暴露了 __tcfapi 和 __gpp,通常在它们背后运行的是同一套状态模型,只是对外提供了两个不同的 API 面。

总而言之,GPP 只是换了信封,并没有换信。EU section 里装的依然是那条熟悉的 TCF v2 字符串——§3–§6 所详述的解码与决策逻辑完全可以原样复用,仅仅是多了一步「从信封里取出 section 2」的操作。

8. 生产环境的坑

以下是那些在事故案例里反复出现的经典模式。

8.1 GVL 版本不一致

GVL 大约每个月都会发布一个新版本。如果 vendor 忘记同步,就会悄悄种下一个慢性的 bug:

修法:务必每天定时同步最新的 GVL 并缓存它。更重要的是,在解码时,一定要去查 TC String 自身声明的那个 GVL 版本(即 VendorListVersion)——而不是简单粗暴地使用你当前的版本。所有的历史版本都被妥善保留在 https://vendor-list.consensu.org/v3/archives/vendor-list-v<N>.json。

8.2 缓存 TTL 活过了有效期

TC String 仅仅反映的是用户在某一特定时刻的状态。用户完全可能在下一秒就:

如果你的 bidder 缓存了 TC String 或任何由它派生的标志,那么缓存的 TTL 必须严格短于 TC String 的有效期(IAB 建议最长 13 个月,但多数 CMP 默认采用 6 个月)。

经验法则:将由同意派生出的缓存 TTL 极限压缩到 1 小时甚至更低。每条 bid request 都应当从它实际带来的那条 TC String 中重新派生决策,而不是依赖跨请求的粘性状态。

8.3 gdprApplies 的三态陷阱

在 CMP API 中,gdprApplies 存在三种状态:true | false | undefined:

IAB 对 undefined 的官方指引非常明确:一律当作 true 处理。这种保守策略是极其正确的。当它经由 OpenRTB 传递时,对应的状态是「regs.gdpr 缺失」,此时你的 bidder 应当毫不犹豫地将其当作 GDPR 适用。

最常见的那个致命 bug 是:开发者错误地进行了 undefined → false 的映射,结果给后来被证明是欧盟用户的人违规处理了数据。直到几个月后法务来索要证据时,这个漏洞才被惊恐地发现。

8.4 滥用 Legitimate Interest

在 TCF v2.0 的早期阶段,vendor 们惊喜地发现,他们几乎可以把每个 purpose 都堂而皇之地声明在 LI 下——「LI 默认开启,用户必须主动反对,通过率简直漂亮极了」。然而,这扇后门在 v2.2 版本被大幅封死:

今天的安全假设:任何 LI 声明都必须经得起严苛的法律审视。重定向和画像建立只能老老实实走 consent 路径——千万别再妄想把它们绕道 LI。

8.5 忽略 Publisher Restrictions

如果一个服务端实现认真读取了 PurposesConsent 和 VendorConsents,却偏偏跳过了 PublisherRestrictions,那么它大概率会违规处理媒体明确叫你别碰的数据。这不仅是对媒体的严重违约,同时也很可能构成一次实质性的 GDPR 违规。

修法:永远先读 publisher restrictions、应用覆盖逻辑,然后再去检查对应的状态位。

8.6 GPP 与 TCF 互相矛盾

在过渡期里,最令人头疼的诡异状态莫过于:一条 bid request 同时携带了 user.consent(TCF)和 regs.gpp(含一个 TCF section 的 GPP 信封),但两者的内容却截然不同。这通常是 SSP 侧的 bug 导致的——SSP 把一条 TC String 复制进了两个位置,但在同意状态发生变化时,却只更新了其中一个。

保守的解法:优先信任 regs.gpp(毕竟这是 SSP 正在积极维护的较新标准),但务必记录下每一次的不一致,以便后续拿去找 SSP 进行对账和修复。

8.7 iOS ATT 与 TCF 相互独立

许多 App 开发者经常把 iOS ATT 和 TCF 混为一谈。实际上,它们是两套完全相互独立的法域规则:

但在欧盟市场,iOS App 必须同时满足这两道门槛:用户必须 (1) 在 ATT 提示里允许追踪,并且 (2) 在 CMP 里授予 TCF 同意。只要其中任何一项为否,IDFA 就绝对无法用于跨 App 用途。

ATTTCF consent能力
允许允许完整功能
允许拒绝IDFA 技术上可访问,但法律上绝对不可用
拒绝允许没有 IDFA——仅能走上下文 + SKAN 路径
拒绝拒绝同上——仅能走上下文 + SKAN 路径

9. 测试与可观测性

9.1 工具

9.2 值得告警的指标

指标你在盯什么经验阈值
gdprApplies == 1 占总流量比骤降 = CMP 误触发偏离基线 ±10%
TC String 解码失败率应为 0;任何上升都是事故> 0.1%
TC String 缺失(在 gdprApplies = 1 时)CMP 没写,或 SSP 没读> 1%
你这家 vendor 的 consent 通过率下跌 = CMP UI 的「全部拒绝」更显眼了,或你的 GVL 声明把 vendor 排除了跌 > 5%
你这家 vendor 的 LI 通过率同上跌 > 5%
逐 purpose 的 consent 通过率找出哪个 purpose 是你的瓶颈跌 > 5%
GPP 出现率跟踪 SSP 侧 GPP 铺开进度单调上升
GPP 与 TCF 不一致率见 §8.6> 1%

9.3 离线对账

建议每月执行一次离线对账:拿同一组字符串,把你服务端解码的输出和 IAB validator 解码的输出进行严密比对。必须做到 100% 一致,没有任何例外。任何微小的不一致,都是一桩潜伏的合规事故。

10. FAQ

Q1:我们不是 TCF vendor——我们只是把 DSP 当客户端用。为什么要关心 TCF?

因为当你上传受众、配置转化像素,或在 DSP 上搭建重定向名单时,你的法律风险完全暴露在控制者(也就是你,广告主)那一侧。深刻理解 TCF 能清晰地告诉你:

Q2:用户点了「全部拒绝」之后,我们还能做什么?

你可以做:

你绝对不能做:

Q3:「全部拒绝」在 CMP UI 里真的是强制的吗?

是的。TCF v2.2 强制要求「全部接受」和「全部拒绝」在 CMP UI 的初始层上必须以同等显著度出现——这是 v2.2 最具影响力的改动之一。它直接将那种把「拒绝」深藏进二级菜单、而「接受」却是个巨大绿按钮的暗黑模式设计判为了违法。

Q4:如果我们永远拿不到 TC String,是不是就被彻底锁在出价之外了?

不一定。如果媒体判定非个性化广告是合适的,DSP 完全可以跑一条上下文路径(仅限 purpose 1、2)——但这条上下文路径必须绝对不碰任何用户级数据,包括 cookie、设备 ID 甚至 IP(请注意,在 GDPR 框架下,IP 属于 PII)。

在实践中,成熟的 DSP 都会精心维护两条相互独立的代码路径:

这里真正硬核的工程挑战,是如何让这两条路径都带着各自的兜底逻辑在生产环境中平稳跑通,同时确保彼此的数据绝对不会发生交叉污染。


延伸阅读

先看同系列(隐私与合规):

跨系列相关:

再是规范与一手资料:

法律 / 政策

协议 / 技术规范

工具 / 实现

相关 / 相邻

附录:术语表


–views
Share this post on:

Previous Post
冷启动:程序化栈每一层都在解同一个问题
Next Post
推荐全链路离线评估:单看召回 HitRate 或排序 AUC 都会骗你