URL 的井号后面为什么适合放密钥,以及什么时候会失效

地址栏里问号和井号只差一个符号。问号后面的查询会写进 HTTP 请求行,服务器、反向代理和访问日志都能看见;井号后面的 fragment 按规范留给当前标签页处理,默认不随这次文档请求发出去。把解密密钥放在 # 后面,挡的是「托管方看见密钥」这一层,不是「完整链接被转发」这一层。

先记一句能核对的结论:打开开发者工具 Network,对照地址栏整串和文档请求的请求行。地址栏可以有 # 后面的密钥,请求行里不该出现同一段。出现了,就不是「浏览器默认行为」,而是实现把 fragment 写进了 query,或脚本主动读出后塞进了请求。

问号和井号不是同一层保护

一次性密文链接常被写成「密钥在 URL 里」。这句话太粗,会把两件完全不同的事叠在一起。URL 可以同时带查询和片段:问号 ? 打开查询,井号 # 打开片段。两者都出现在地址栏,接收方复制时也常常整串带走,但对 HTTP 的待遇不一样。

查询会成为请求目标的一部分。你打开 s.html?id=abc123,服务器、反向代理、CDN 和访问日志按设计都能看到 id=abc123。片段默认不进入这次文档请求。你打开 s.html?id=abc123#密钥,浏览器向服务器要的是带 id 的那一页,井号之后留在当前标签页,给页面脚本用 location.hash 读取。

所以「密钥在 URL 里」只回答了人能不能看见整串,没有回答服务器能不能看见密钥。适合放密钥的是井号后面那一段,前提是实现真的把它留在 fragment,而不是图省事写进 query。

规范怎么写:fragment 留给客户端

这不是某个产品的私有约定。RFC 3986 第 3.5 节把 fragment 定义成「次级资源」标识:井号出现后一直到 URI 结束。片段怎么解释,取决于取回来的文档类型,由客户端处理,不由 URI 方案改写。

RFC 9110 第 7.1 节把这条规则接到 HTTP 上:浏览器解析出的目标 URI 不含 fragment,因为片段标识留给客户端处理。发文档请求时,请求行里不应出现井号及之后的内容。

Referer 也按同一原则剥离。RFC 9110 写明,用户代理生成 Referer不得带上 fragment 和 userinfo。MDN 对 Referer 的说明与此一致:头里可以有源、路径和查询,不可以有 # 片段。W3C Referrer Policy 在「把 URL 收成 referrer」的步骤里,会先把 fragment 置空,再按策略决定还留不留路径和查询。

这三份材料合在一起,只证明一件事:按规范实现的浏览器,不会把井号后面的密钥写进 HTTP 请求行或 Referer。它不证明页面脚本、扩展或你粘贴到别处的文本也会自动删掉这一段。

密钥若放进问号,日志里就是明文

把密钥写成 ?id=abc123&key=...,实现更简单:服务端用同一套 query 就能取密文、再代为解密。对「图省事的在线加密页」这很常见,对「托管方不该看见密钥」则直接相反。

查询会出现在请求行,也会进入大多数访问日志、反向代理日志和部分 CDN 报表。密钥一旦进 query,所谓「服务端只存密文」在日志这一层就不成立:运维打开当天的 access log,就能看到解密所需的第二半。

有人会改成 POST body 传密钥。那能避开 URL 日志,但密钥仍然离开了浏览器,到达了你声称「零知识」的那台机器。fragment 的价值恰恰是:密钥根本不必作为 HTTP 字段发出去,页面在本机读 location.hash 即可。

放哪里 人能不能看见 这次 HTTP 能不能看见
问号后的 query 能,在地址栏和复制文本里 能。请求行、代理和访问日志都会记
井号后的 fragment 能,在地址栏和复制文本里 默认不能。请求行和 Referer 按规范不含这一段
POST 字段 地址栏没有 能。请求体到达服务器
只在本机内存、从不写入 URL 对方看不到,除非另建安全信道 不能。但一次性链接也无法靠「打开即用」完成交接

正确拆法:定位走问号,密钥走井号

一次性密文链接要同时完成两件事:告诉服务器「取哪一条密文」,以及告诉接收方浏览器「用哪把密钥在本机解密」。这两件事不该走同一条 HTTP 字段。

可核对的拆法是:s.html?id={id}#{key}。问号只带定位用的 id,井号只带密钥。创建时,浏览器用 Web Crypto 做 AES-256-GCM,出站只允许密文;阅读时,脚本读 location.hash,再向服务器要密文、在本机解密。服务器按设计只暂存密文,阅后焚毁。

MakePwd 的阅后即焚按这个边界实现,创建页和阅读页都打开即用,没有账号。阅读页对接收方公开,不要求登录。这仍然只是实现选择,不是「井号等于加密协议」。fragment 本身不提供保密性,它只是避免密钥作为 HTTP 字段离开标签页。

不要用真实口令、证件号或未脱敏表格做实验。准备一段可以丢弃的测试句,密钥用这次才生成的一次性链接。核对的是请求行和地址栏,不是把秘密再传播一遍。

用 Network 和地址栏当场核对

上一篇写过如何用金丝雀检查「明文有没有当业务数据离开本机」。这一篇把范围收窄:只核「密钥有没有进 HTTP」。步骤可以单独做完,不必先读完那一篇。

先创建一条无害测试文本,复制生成的完整链接。看清楚井号在哪:前面是 s.html?id=...,后面才是密钥。然后换一个干净的标签页,打开开发者工具,勾选 Preserve log,再粘贴打开。

  1. 对照地址栏和文档请求的请求行。地址栏应保留 # 及之后的内容;文档请求的 URL 只应看到 path 和 ?id=,不应出现井号后的密钥。
  2. 再看后续 XHR / Fetch。取密文的接口按设计可以带 id,请求体或查询里不应再出现同一段密钥。创建接口的 body 应是密文,不是你刚输入的测试句。
  3. 单独打开分析上报的 query 与 body。页面路径可以出现;location.href 若被整串写入,密钥会从「不进 HTTP 默认行为」变成「脚本主动上报」。这是实现问题,不是规范失效。

三次都干净,只能支持很窄的结论:在你使用的这个浏览器、这一次打开里,密钥没有作为已观察到的 HTTP 字段离开标签页。换浏览器、换版本、或页面改了脚本之后,需要重新做。

这一层保护在什么时候失效

fragment 挡的是「这次 HTTP 把密钥送给托管方」。下面这些场景里,密钥本来就不靠 HTTP 默认行为保密,规范帮不上忙。

第一,完整链接被贴进聊天、邮件或工单。接收端看到的是人眼可见的整串,井号后面一起被存进历史记录。有的客户端会丢掉 hash,只预览问号前面的部分——那时接收方打开后缺密钥,阅读页应提示缺密钥,而不是向服务器再要一把。无论哪种,你已经把「持有即用」的凭证交给了另一个系统。

第二,页面脚本和扩展能读 location.hash。这是阅读页能工作的原因,也是 XSS 或恶意扩展能拿走密钥的原因。RFC 9110 第 17.11 节提醒过:片段不会进请求,但仍对用户代理、扩展和响应带来的脚本可见。重定向若继承原 URL 的 fragment,还可能把本站片段带到另一个站点。

第三,浏览器历史、屏幕共享和剪贴板。地址栏里的整串会出现在本地历史里;你把标签页投到会议室,井号后面同样在屏幕上。这些都不经过服务器,也不能用「fragment 不进 HTTP」来反驳。

第四,会改写 URL 的跳转页或短链服务。中间页如果只转发 path 和 query,接收方打开时 hash 已经没了;如果它先在自己的页面用 JavaScript 读完整 href 再跳转,密钥就进了中间方的前端。短链尤其要当场看:你交给对方的那一截,还是不是带 # 的原串。

三个容易说反的地方

「井号后面更安全,所以整串可以随便转发。」不成立。对服务器更安全,对群聊记录不一定。完整 URL 是凭证,谁拿到谁就能打开阅读页解密。

「Referer 可能把密钥漏给外链。」按现行规范,浏览器生成 Referer 时必须去掉 fragment。真正要防的是页面自己把 location.href 写进分析、日志或第三方脚本。核对方法仍然是看 Network 的上报内容,而不是假设「有外链就一定漏密钥」。

「阅读页应当先登录,否则谁都可以打开。」这把「谁持有完整链接」和「谁拥有账号」混在一起了。一次性密文链接的访问控制就是整串本身。给接收方加登录门禁,并不能让服务器看不见密钥——密钥本来就不该发给服务器。MakePwd 的阅读页对接收方公开,创建与阅读都无需账号。

常见问题

井号后面会发给服务器吗?

默认不会。RFC 9110 写明目标 URI 不含 fragment。打开 Network,文档请求的请求行里不应出现 # 及之后的密钥。服务器按设计只看到路径和 ?id=

为什么不把密钥放在问号后面?

问号后的查询会写进 HTTP 请求行,托管方和访问日志都能看见。密钥一旦进 query,「只存密文」在日志这一层就不成立。定位用 id 可以走问号,密钥应走井号。

把完整链接贴进聊天还安全吗?

对服务器仍然看不到密钥;对聊天记录、工单和浏览器历史不再成立。完整 URL 持有即用。需要交接时,应确认对方拿到的是带井号的原串,并清楚这串会出现在对方的历史里。

打开阅读页需要登录吗?

不需要。阅读页对接收方公开:问号里的 id 用来取密文,井号里的密钥在本机解密。MakePwd 没有账号和密码库,创建与阅读都打开即用。

下次传一次性秘密时记住的三件事

第一,看符号,不看「密钥在 URL 里」这句空话。问号进 HTTP,井号默认不进。第二,创建时确认出站的是密文,阅读时确认请求行没有 # 后面那一段。第三,把完整链接交给对方之前,先问自己:这串会不会进聊天记录、工单或会丢掉 hash 的短链。后两处失败,与服务器是否零知识无关。

若你还要核对「明文有没有当业务数据离开标签页」,那是上一篇的范围:用金丝雀搜 Network 的请求体和分析上报。本文只把「密钥为什么可以放在井号后面」收成可以对照规范和请求行的判断。