Accept、Accept-Language、Accept-Encoding 的取值与顺序在 JavaScript 执行前就构成指纹——禁用脚本也无法阻止。
屏蔽所有脚本、禁用 Cookie,甚至用 NoScript 把浏览器锁得死死的——服务器依然能给你打上指纹。每一次 HTTP 请求都携带一组请求头,描述你的浏览器接受什么内容、使用什么语言、支持哪种编码,而这些请求头在任何一行 JavaScript 执行之前就已经送达。它们的取值、顺序,以及哪些请求头会出现,都会因浏览器而异——这种差异相当稳定,很难伪造得天衣无缝,而且服务器在收到第一个请求时就能读到。这是一种完全不需要 Canvas、不需要 WebGL、不需要 navigator 对象的指纹识别——只靠请求行本身就够了。
核心要点
- Accept、Accept-Language、Accept-Encoding 携带真实的熵。 它们的取值反映了已安装的语言偏好和编解码器支持,组合起来缩小访客范围的方式,和Canvas 或 WebGL如出一辙——只是完全不需要任何脚本运行。
- 请求头的顺序本身就是一个独立于取值的信号。 每种浏览器的网络栈都以固定顺序发出请求头;这个顺序因 Chrome、Firefox、Safari 而异,且同一安装版本发出的请求之间几乎不会变化。
- 这一切都不需要 JavaScript。 服务器在第一个请求上就能看到完整的请求头集合——早在 HTML 被解析之前,更不用说脚本标签运行之前——所以拦截 JS 和 NoScript 对它都无能为力。
- 它位于 Client Hints 之下,而非取代 Client Hints。 User-Agent Client Hints 在始终会发送的 Accept 系列请求头之上,又叠加了一层 HTTP 层信号:少数低熵提示默认就会发送,而详细的那些只有在服务器通过
Accept-CH主动索取时才会发送。两者都早于 JavaScript 触发。 - 它会与下方的 TLS 和 TCP 层叠加。 TLS 指纹识别读取的是这些请求头之下的加密握手过程,因此同时检查这两层的服务器,能拿到一个完全不依赖页面是否加载成功的请求级签名。
请求行本身就已经是一个指纹
在浏览器渲染任何内容之前,它已经发出了一个 HTTP 请求,其中携带若干请求头,而这些请求头的全部作用就是内容协商:告诉服务器浏览器能显示什么、用什么语言,以及希望响应以何种方式压缩。在 HTTPS 下,这个请求在传输途中是加密的,但终结连接的那台服务器读到的是明文。一个典型的 Chrome 请求大致如下:
GET / HTTP/1.1
Host: example.com
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8
Accept-Language: en-US,en;q=0.9
Accept-Encoding: gzip, deflate, br, zstd
在 HTTP/2 和 HTTP/3 中,同样的信息以二进制压缩字段的形式传输,字段名全部小写,Host 也被 :authority 伪请求头取代——但内容协商的语义完全一致,"先于任何脚本抵达"这一点也完全一致。(分帧层本身还会带来额外的信号,参见HTTP/2 指纹识别。)
这里没有任何一项需要脚本执行——这正是用来获取 HTML 文档本身的那个请求。仅记录这三个请求头,服务器就已经掌握了比大多数人想象中更多的信息:Accept 系列请求头——Accept、Accept-Language、Accept-Encoding——绝非样板文字,它们是按浏览器和配置逐一协商出来的;而 RFC 9110——当前的 HTTP 语义标准——定义了质量值(q=)语法,让每个浏览器都能表达自己的偏好排序,而这正是差异的来源。
每个请求头究竟泄露了什么
Accept-Language 是最直接的信号。它按偏好顺序列出用户配置的语言,并用 q 值表示相对权重。多语言用户——例如配置了 en-US,en;q=0.9,fr;q=0.8,de;q=0.7 的用户——暴露出一个具体且相对罕见的组合;而具体的 q 权重格式也会因浏览器和操作系统区域设置的不同而略有差异,这又添加了一层信息,不仅是"配置了哪些语言",还有"这一特定技术栈如何格式化语言列表"。
不过有一点值得说清楚:单看这个请求头,它的价值其实有限。一个只发送 en-US,en;q=0.9 的访客,和数量庞大的人群共享同一个取值,因此它本身几乎提供不了多少可识别信息。熵集中在那些不寻常的情况里——三四种语言的列表、罕见的地区变体、与出口 IP 所在国家对不上的语言——以及它与请求中其余信息的组合。这与 JavaScript 侧那些信号所遵循的熵与匿名集数学是同一套逻辑。
Accept-Encoding 列出客户端能够解码的压缩算法:gzip、deflate、br(Brotli),以及日益普及的 zstd。新算法的支持是逐浏览器、逐版本推出的,因此具体的算法集合——以及它们的列出顺序——不仅能缩小到"某个 Chromium 浏览器",还能大致定位到发出请求的版本范围。
Accept 描述浏览器愿意渲染哪些内容类型和子类型,并按偏好加权——它直接揭示了诸如是否支持 AVIF 或 WebP 图像格式这类信息,而这与浏览器家族及版本高度相关。
单独来看,每个请求头都只能略微缩小范围。但组合起来读取,它们的表现与Canvas 和 WebGL 指纹背后的熵计算完全一致:一组彼此独立的信号,各自贡献的可识别信息比特会不断累加。
请求头的顺序与存在与否:藏在取值之下的签名
会变化的不只是取值——浏览器发送请求头的集合与顺序本身也几乎是一个固定签名。每种浏览器的 HTTP 客户端库都按自家网络代码构建请求头时的既有顺序来组装它们,这个顺序在同一浏览器、同一版本的多次请求间保持一致,但在不同浏览器之间各不相同。一个声称自己是 Chrome、请求头顺序却和 Python 的 requests 库或 curl 如出一辙的请求,当场就露了破绽——让 TLS ClientHello 顺序成为指纹的那套逻辑,在更上一层——HTTP 分帧层——同样适用于普通的请求头顺序。而由于 HTTP/2 和 HTTP/3 在此之上又强加了各自的伪请求头约定,分帧层本身也携带了额外的信号,普通 HTTP/1.1 代理或脚本库往往会在这个细节上出现微妙的错误。
这也是为什么请求头集合和任何单一取值一样重要:真正的浏览器在每次导航时都会发送一组可预测且完整的请求头;而一个只设置了 User-Agent、忘了 Accept-Language 或 Accept-Encoding——或者以任何真实浏览器都不会采用的顺序发送它们——的脚本化客户端,恰恰会因为缺失或错位的东西而暴露自己,而不是因为某个单一取值看起来不对劲。
为何这种方式能扛住禁用 JavaScript
本站介绍过的那些 DOM 层指纹信号——Canvas、WebGL、音频、字体、权限——都需要一段脚本运行并调用某个 API。而 HTTP 请求头完全不需要这些。它们由浏览器的网络栈附加在获取 HTML 第一个字节的第一个请求上,这意味着:
- 完全禁用 JavaScript(NoScript、纯文本浏览器)没有任何效果——请求头照样会发出去。
- 拦截第三方脚本或追踪器同样帮不上忙,因为提供页面的第一方服务器能直接看到这些请求头。
- 即便一个请求从未渲染出页面——一次 HEAD 请求,或一次从未走完的重定向——依然携带完整的请求头集合。
正是这种特性让 TLS 与 TCP/IP 指纹识别同样能扛住脚本拦截:任何存在于网络层或协议层、位于 DOM 和 JavaScript 引擎之下的信号,都完全不在依靠禁用脚本运作的隐私工具的触及范围之内。
它如何与 Client Hints 和 TLS 叠加
仅凭 HTTP 请求头进行指纹识别并不是要取代其他网络层信号——它是这些信号共同依附的那一层,或与之并列的一层:
| 层级 | 信号 | 需要 JS? | 需要 HTTPS? |
|---|---|---|---|
| TCP/IP | 窗口大小、TTL、选项顺序 | 否 | 否 |
| TLS 握手 | 来自 ClientHello 的 JA3/JA4 | 否 | 是 |
| 普通 HTTP 请求头 | Accept/Accept-Language/Accept-Encoding 的取值+顺序 | 否 | 否 |
| Client Hints | Sec-CH-UA-* 低熵默认值 | 否 | 是 |
| Client Hints(高熵) | 完整版本号、架构、型号 | 否(但需服务器发送 Accept-CH 索取) | 是 |
| 浏览器 API | Canvas、WebGL、字体、音频 | 是 | 否 |
沿着这整套层级往下读的服务器,会得到一个完全在——且独立于——任何基于 JavaScript 的信号之前就已组装好的签名,再为那些确实执行了脚本的访客叠加上 DOM 层的信号。Accept 系列请求头与 Client Hints 都属于 HTTP 层,也都不依赖 JS;区别在于 Client Hints 目前只有 Chromium 系浏览器支持,且更丰富的取值需要服务器主动索取,而普通的 Accept 请求头则来自每一种浏览器的每一次请求,无需任何协商。
检查你自己的请求头
这条分界线的两侧,你都可以亲自看一眼。BrowserInsight 的指纹检测工具会在一堆 JavaScript 侧信号旁边,显示你的浏览器向脚本暴露的语言列表(navigator.languages);同时它还会显示页面自己算不出来的那一个网络层信号:服务器观测到的本次 TLS 握手,包括 JA3/JA4、TLS 版本和 ClientHello 长度。那张握手卡片对应的正是本文所讲这些请求头之下的那一层——同样具备"先于任何脚本"的特性,只不过读它的是服务器,而不是页面。
想把它和请求头这一层对照着看,可以记下工具里显示的语言列表,然后用任意一个能回显原始请求的服务,或者直接用浏览器自带的开发者工具(打开 Network 面板,点开文档请求,查看 Request Headers),看看浏览器实际发出的 Accept-Language 是什么。两者在细节上往往并不一致——navigator.languages 和 Accept-Language 源自同一份偏好设置,但各自独立格式化——而这种不一致,恰恰是检测系统在找的东西。改一改语言设置,或换一个浏览器重做一遍,你能亲眼看到两侧同时发生变化。
常见问题
使用 VPN 会改变这些请求头吗?
不会。VPN 改变的是你的 IP 地址,而不是浏览器的网络栈——Accept、Accept-Language、Accept-Encoding 由浏览器自身生成,并原样穿过 VPN 隧道。如果你的 Accept-Language 仍然显示 en-US,而 VPN 出口 IP 的地理定位却指向另一个国家,这种矛盾本身就是一个信号,类似于其他工具会检测的时区与 IP 不匹配。
我能伪造这些请求头,让自己看起来像另一种浏览器吗?
取值可以——任何 HTTP 客户端库都允许你设置任意请求头。但要在每一次请求中都始终如一地匹配某个真实浏览器精确的请求头集合与顺序,包括 HTTP/2 如何对它们分帧,就困难得多——这正是为什么不匹配或不完整的请求头集合常常成为脚本化流量的破绽,而不仅仅是取值本身被伪造。
这和 TLS 指纹识别是一回事吗?
相关但不同。TLS 指纹识别读取的是在任何 HTTP 请求发出之前就完成的那次握手;而 HTTP 请求头指纹识别读取的是握手完成后紧接着发出的那个请求——它在传输途中是加密的,但终结连接的服务器能完整读到。两者都不依赖 JavaScript,而服务器越来越倾向于同时检查这两层,因为一个请求完全可能通过其中一项检查却在另一项上露馅。
禁用 Cookie 能阻止这种指纹识别吗?
不能——Cookie 与此无关。基于请求头的指纹识别不会在你的设备上存储任何东西;它读取的是浏览器在每次请求中默认发送的内容,这和广义上的浏览器指纹识别同样不依赖任何存储的数据。
结论
多数关于指纹识别的讨论都围绕 JavaScript 驱动的信号展开——Canvas 哈希、WebGL 渲染器字符串、字体枚举——因为这些是追踪者可用的、熵值最高的信息来源。但在更下面一层,还存在一个有意义的签名:每个浏览器在任何脚本有机会运行之前发送的那些普通 HTTP 请求头——接受什么、用什么语言、支持什么编码,以及它们的顺序。禁用 JavaScript 能关闭 DOM 层的信号;但对请求行本身毫无影响。与其下方的 TLS、TCP 指纹以及与之并列的 Client Hints 结合起来,这个仅凭请求头就存在的信号层意味着:根本不存在真正"无信号"的请求——只存在没人愿意深挖到页面之下的请求。
延伸阅读:


