先记结论:井号后面适合放密钥,是因为浏览器发 HTTP 时会丢掉 fragment,不是因为多了一套加密协议。核对方法是对照地址栏和 Network 的请求行。把完整链接贴进聊天、工单或浏览器历史时,这一层保护对「看见整串的人」不再成立。
一句话先回答搜索意图
URL 的井号后面适合放密钥,因为 RFC 9110 规定:请求的目标 URI 不含 fragment。浏览器向服务器要文档时,会去掉 # 及之后的部分。托管方、CDN 和访问日志按设计只能看到路径和问号后的查询,看不到井号后面的密钥。
这篇文章给开发者、运维,以及要把一次性链接发给同事之前想先分清「服务器看不见」和「聊天记录看不见」的人。它不代替 阅后即焚 的操作说明,也不重复「怎么创建一条链接」。你要带走的是:问号和井号各自进哪一层、用 Network 怎么对照、以及把整串 URL 转发出去时会丢掉哪一层保护。
问号进 HTTP,井号默认留在标签页
同一条地址可以同时带查询和片段。形态是 https://example.com/s.html?id=abc#key。问号后面的 id=abc 是 query,井号后面的 key 是 fragment。浏览器解析时,fragment 从第一个 # 一直到字符串结束;更前面的组件里如果要写字面量井号,必须写成 %23,否则解析会提前截断。
发文档请求时,浏览器组装的是 Host、路径和查询。MDN 对 URI fragment 的说明很直白:片段在请求资源时不会发给服务器,由客户端在拿到文档之后再处理。HTML 里它常用来滚到某个 id;在阅后即焚里,它被借来承载本机解密要用的密钥。
因此「服务器只存密文」如果成立,前提必须是:定位用的编号走 query,解密密钥走 fragment,并且页面脚本没有把 location.hash 再写进后续请求。少任何一步,井号就只是装饰。
规范写的是默认行为,不是额外加密
RFC 3986 §3.5 把 fragment 定义成对「主资源之上的次级资源」的标识,由客户端按文档类型解释。RFC 9110 §7.1 接着写明:目标 URI 排除引用里的 fragment,因为片段标识留给客户端处理。两份文件叠在一起,意思是:井号后面不是 HTTP 请求目标的一部分。
这也解释了为什么访问日志里找不到 #。不是运维把字段藏起来了,而是请求行里本来就没有这一段。想用服务端日志统计「用户点了哪个锚点」,本身就会落空;想用同一份日志证明「密钥没进服务器」,却正好对得上。
Referer 也按同一原则处理。MDN 对 Referer 的说明是:这个头可以带源、路径和查询,但不得包含 URL fragment 和用户名密码。Referrer Policy 在「把 URL 收成 Referer」时会先把 fragment 置空。所以从阅读页再去拉一张图、一个脚本,默认也不会把井号后面的密钥带给第三方主机。这是浏览器的默认剥离,不是站点自己发明的协议。
页面脚本仍然读得到片段。MDN 的 URL.hash 就是 # 加上片段本身;当前页也可以用 location.hash。阅读页正是靠这个接口取出密钥,再调用 Web Crypto 做 AES-256-GCM 解密。同一接口也能被误用:脚本可以把 hash 拼进 query,或写进分析上报。规范挡的是「浏览器默认发出的请求行」,挡不住「页面自己读出来再发送」。
不要用真实口令、API Key 或未脱敏连接串做实验。准备一句只有这次才有的测试文本。核对的是请求行和请求体,不是把凭证再暴露一遍。
用 Network 对照地址栏和请求行
口号不能当证据。能当场做完的检查,是打开开发者工具的 Network,把地址栏整串和每一条请求的 URL 放在一起看。上一篇写过用金丝雀搜明文;这一篇只盯 fragment:井号后面那一段,应出现在地址栏,不应出现在请求行。
- 打开阅读页,确认地址栏是
s.html?id={id}#{key}这种形态:query 只有编号,密钥只在井号后面。 - 打开 Network,勾选 Preserve log,过滤器先用 All。文档请求、后续接口、分析上报都不要先藏起来。
- 把井号后面的密钥当成金丝雀,粘进过滤器。有红色命中就停下来读那条请求:实现把 fragment 错写成了 query,或脚本读出后又发了出去。
- 没有命中时,再逐条打开文档请求和
/secrets一类接口,对照请求行是否只含id,请求体是否没有原文。
创建侧也可以做一次。本机用 AES-256-GCM 加密后,出站字段按设计只有密文、过期时间和阅读次数;随机密钥应只出现在随后拼好的分享链接里,不应出现在 POST 的 JSON 里。FastPwd 的阅后即焚按这条边界实现:创建和阅读都打开即用,没有账号。承诺仍然只是承诺,以你这一次在 Network 里看到的为准。
把 %23 和真正的 # 分清。请求行里出现 %23 后面的字符,表示这段被当成路径或查询发出去了,服务器看得到。只有未编码的 # 才开始 fragment。有人把密钥写成 ?key= 或 /%23key,看起来像「藏在井号里」,对日志来说已经是明文查询。
页面脚本和分析上报仍能读走 hash
默认 HTTP 请求不含 fragment,不等于「这个标签页里没有任何脚本能看见它」。当前文档的 JavaScript 拥有 location.hash。阅读页必须读它,才能在本机解密;分析脚本如果把 location.href 整串上报,密钥就会出现在统计请求的 query 或 body 里。
所以金丝雀搜索不能只看业务接口。单独过滤访问统计路径,打开 query 与 body。页面标题和路径可以出现;井号后面的密钥、待测密码和清洗前原文不应出现。FastPwd 的访问统计走站点自己的 tracker,localhost 默认不发送;即便如此,你仍应用同一枚金丝雀确认上报里没有 fragment 密钥。
扩展、企业 DLP 和某些「AI 浏览器」也能读地址栏全文。它们不走你正在看的那条 HTTP 请求行,Network 面板不会替你证明它们没读。把检查收成一句能对同事说的话:「我搜过请求行和上报,没有井号后的密钥。」不要把它说成「这台电脑上没有任何软件看见过这段 hash」。
什么时候这层保护会失效
fragment 挡的是「这次文档请求把密钥交给托管方」。下面这些场景里,看见整串 URL 的人已经等于持有密钥,规范帮不上忙。
第一,把完整链接贴进聊天、邮件或工单。消息正文里的字符串包含井号后面的部分。对方的客户端、群历史、工单搜索和将来的导出,都能看到整串。对服务器来说密钥仍可能没进 HTTP;对「能打开聊天记录的人」来说,保护已经没了。完整 URL 是持有即用的凭证。
第二,浏览器历史和同步。地址栏显示过的 # 片段,通常会进本地历史。开了同步的浏览器,还可能把同一条记录送到你的其他设备。清掉标签页不等于清掉历史。
第三,重定向可能带走 fragment。目标 Location 如果自己没有片段,不少浏览器会把原来的 fragment 接到新地址后面。密钥于是出现在你以为「已经跳到安全页」的地址栏里。阅读页若再被重定向到统计域或登录域,先用 Network 看最终请求行,再看地址栏还剩什么。
第四,链接预览和安全扫描会去拉「井号前面」那一段。聊天软件生成预览时,按规范不会把 fragment 发给被预览的主机,但它们会请求 s.html?id=…。如果实现在第一次 GET 文档或第一次 GET 密文时就焚毁,预览机器人可能先于接收方把次数用掉。接收方点开时只看到「已被焚毁」,密钥其实从未交给预览主机——烧掉的是阅读次数,不是 fragment 保密性。
FastPwd 的阅读页把这两步拆开:打开页面时只查询密文还在不在,不取走密文;接收方点「打开并查看」之后,才按编号取密文,并用井号后的密钥在本机解密。创建页和阅读页都不需要账号。预览机器人通常只会拉落地页,不会替你点按钮;这能降低「预览即焚毁」的概率,但不能保证所有扫描器都停在落地页。次数仍然是服务器侧的计数,不是 fragment 的一部分。
| 谁在看 | 看不看得到 # 后的密钥 | 这一层还成不成立 |
|---|---|---|
| 文档请求的请求行 | 默认看不到 | 成立;用 Network 对照 |
| 托管方访问日志 / CDN | 默认看不到 | 成立,前提是密钥没进 query |
| Referer 发往第三方 | 默认看不到 | 成立;规范会去掉 fragment |
| 页面脚本 / 分析上报 | 能读 location.hash |
看实现;用金丝雀搜 body |
| 聊天、邮件、工单正文 | 看得到整串 | 对转发对象不成立 |
| 浏览器历史 / 同步 | 通常看得到 | 对能打开这台浏览器的人不成立 |
| 链接预览机器人 | 按规范拿不到 fragment | 可能消耗阅读次数 |
你能证明什么,不能证明什么
这次检查能支持的结论很窄。能支持:在你使用的这个浏览器、这一次打开里,文档请求和已观察到的接口请求行没有带上井号后面的密钥;创建请求的 body 里没有原文。
不能支持:群聊管理员没保存过完整链接;浏览器扩展没读地址栏;对方打开后不会截图;密文被取走之后不会再出现在别的备份里。阅后即焚删除的是服务器上的密文,不是已经解密到屏幕上的明文,也不是聊天记录里的整串 URL。
也不要把 fragment 理解成「只有接收方能打开」。谁持有 ?id=…#密钥 整串,谁就能在公开的阅读页解密。阅读页不设登录门禁,这是设计:对方不必先注册。代价是整串必须按凭证来保管,而不是按「普通可转发的文档链接」来保管。
常见问题
URL 井号后面的内容会发给服务器吗?
默认不会。RFC 9110 写明目标 URI 不含 fragment,浏览器发文档请求时会去掉井号及之后的部分。服务器、反向代理和访问日志按设计只能看到路径和问号后的查询。若请求行里出现了井号后的密钥,那是实现错误,不是规范例外。
为什么不把解密密钥放在问号后面?
问号后的查询会写进 HTTP 请求行。托管方、CDN 和访问日志都能看见。密钥一旦进 query,所谓「服务端只存密文」在日志这一层就不成立。编号可以放在 query 里,因为它本来就要用来取密文;密钥不行。
把带井号的完整链接贴进聊天还安全吗?
对服务器仍然看不到密钥;对聊天记录、工单和浏览器历史不再成立。完整 URL 是持有即用的凭证,谁拿到整串,谁就能在阅读页解密。需要更高隔离时,把「带 id 的链接」和「井号后的密钥」分两个渠道给,而不是把整串丢进同一个群。
打开阅后即焚阅读页需要登录吗?
不需要。阅读页对接收方公开,靠问号里的 id 取密文、靠井号里的密钥在本机解密。FastPwd 没有账号和密码库,创建与阅读都是打开即用。登录门禁解决不了 fragment 的边界问题,也不应被写进这一层检查。
用一条无害测试链接当场核对
若你希望用一个把 query 和 fragment 拆开的页面来练习,可以从 FastPwd 的阅后即焚开始。打开即可使用,无需注册。写入一句可丢弃的测试文本,生成本机加密后的链接。创建请求里你应只看到密文和次数、过期一类字段;分享链接才是 s.html?id={id}#{key}。
用这串链接打开阅读页。地址栏应同时有问号和井号。Network 里,文档请求和状态查询应只带 id。点「打开并查看」之后,取密文的请求同样不应带上井号后面的密钥;明文只应出现在本机解密之后的页面上。算法是 AES-256-GCM,计算走 Web Crypto。单条文本上限 32 KB;整份文件请改用 文件加密盒,单文件不超过 5 GB,输出 .lock / .enc。
这些练习的目的不是证明某一个站点「绝对安全」,而是让你分清两层事实。第一层:托管方按规范看不到 fragment。第二层:看见完整链接的人等于持有密钥。换任何声称「密钥在井号后面」的页面,步骤不变:对照地址栏和请求行,再单独看分析上报,最后问自己这串 URL 会不会进聊天记录。
下次发送前记住三件事。第一,编号可以进 query,密钥只能进 hash。第二,请求行干净,只说明服务器没拿到密钥。第三,完整链接按凭证保管,不要当普通文档链接转发。能重复的观察,才值得写进你们自己的交接说明。
延伸阅读
上一篇把「明文有没有离开这个标签页」收成可重复的 Network 检查,适合和本文对照着做:浏览器本地加密怎么核对明文没有离开本机。本文只把问号和井号拆开,并写清把完整链接转发出去时会失效的那一层。