浏览器拿一个一次性 code,再由服务端把它兑成访问令牌。

OAuth 授权码用码换令牌

让令牌只留在后端、只把短命的一次性 code 交给浏览器,能从很大程度上缩掉令牌在 URL 与日志里被偷走的地盘。 浏览器只拿 code access token 不该出现在浏览器里,这正是授权码流程存在的理由。浏览器跳转到授权端点、看到确认页,然后带着一个短命的一次性 code 回到回调地址——它从头到尾拿不到 token 本身。因为真正拿 token 的是后端:后端拿着 code,配上自己的 client secret,去 token 端点换。这个分工——浏览器只搬运 code,后端才掌握 secret——就是整个流程的意义所在。 后端负责兑换 交换发生在服务端,意味着 access token 只落在后端手里:浏览器既不会把它记进历史,客户端一次脚本 bug 也不会把它泄漏出去。后端构造 code + client_secret + redirect_uri 发给 token 端点,换回一个 JSON 的 access token(可选地还有 refresh)。这也解释了为什么没有 client secret 的单页应用(SPA)与原生应用,在这里总是敏感目标:它们没有 secret,所以需要哪「authorization code + PKCE」的组合,而不是普通 code flow。code flow 用一次后端的往返,换来 token 不经过浏览器这份安全。 ...

十月 20, 2027 · 2 分钟 · XB Field Notes
以观察、判断和行动三个面板呈现 HTTP Basic 认证机制。

Basic Auth 携带的是可重复使用的凭据

HTTP Basic 认证把编码后的用户名与密码放进 Authorization 请求头。由于编码可逆,取得该请求头的值,往往就相当于取得凭据本身。Basic 确实定义了认证挑战与响应流程,但不会用密码学方式把响应绑定到服务器刚生成的一次性随机数。只要服务器继续接受这个账号和凭据,一份曾被接受的值就可能被再次使用。不能把“存在挑战流程”误解成“重放一定失败”。 看清真正发生的交换 服务器可以返回四零一,并通过 WWW-Authenticate: Basic 挑战声明 realm,客户端再带 Authorization 重试。客户端也可以在合适的保护空间内提前发送凭据。因此,带认证的请求会携带凭据,不意味着每个最初请求一开始就必须有它。realm 用于标识保护空间,并不会自动授予同名路径上的所有权限,更不能替代具体的资源访问控制。 编码输入由用户标识、一个冒号和密码拼接。第一个冒号是分隔符,所以用户名不能包含冒号,密码则可以包含后续冒号。Base64 既不是加密,也不是密码哈希。涉及非 ASCII 凭据时还要考虑字符编码:RFC 7617 在兼容 ASCII 的条件下没有统一默认编码,并允许服务器挑战指明 UTF-8。仅验证一个 ASCII 示例,不等于已经覆盖所有国际化账号的编码与比较路径。 Basic 本身不定义会话令牌或通用退出登录协议。应用仍可以设置账号到期、禁用账号、改变授权或拒绝旧密码。因此,轮换密码并不是阻止泄露请求头继续生效的唯一办法。凭据的有效期与撤销方式,应依据服务器真实的账号策略定义,不能期待网络传输格式自动提供完整账号生命周期管理。客户端停止发送,与服务器已经拒绝旧值,也是不同事实。 坏掉的样子 在没有保护的网络链路上,能够读取请求头的人可以解码并再次使用。正确校验证书的 HTTPS 保护的是到 TLS 终点的传输,不会让终点本身、合法终止 TLS 的代理或疏忽的应用日志看不到凭据。还需要保护后续链路,并避免在日常追踪、报告和共享诊断文件中记录 Authorization 的真实值。传输加密不能替代服务端对凭据的妥善处理。 权限过大的凭据会扩大泄露后果。账号应匹配具体任务及其生命周期,身份验证之后仍要执行授权。凭据有效,不等于每个操作都允许;realm 标签也不是租户隔离机制。客户端需要限制凭据发送位置,尤其关注重定向和脚本复用全局请求头的情况。把同一个认证头传给无关目的地址,超出了原来的保护空间,也破坏了凭据使用边界。 重复的四零一可能来自凭据缺失、错误、到期或服务端不支持相应方式。在下面例子中,四零三表示已经识别的演示账号缺少目标权限,但不能据此认为所有服务返回四零三都证明认证已成功。应结合服务契约及响应中的认证挑战检查,不能仅凭一个数字推断完整的身份状态。认证结果和业务许可,应分别提供能够判断的证据。 快速验证 保存为 basic-auth.py,执行 python3 basic-auth.py。所有凭据都是刻意编造的演示值:用户名为 DEMO,密码为 NOT:A:SECRET。服务器只绑定回环地址,用明文 HTTP 展示协议机制;不要把这些值替换成真实凭据。实验没有测试 TLS、持久账号存储或生产认证服务,也不会读取当前环境的登录信息。 ...

二月 11, 2027 · 2 分钟 · XB Field Notes
浏览器里一个小令牌,指向服务器上保存的状态。

Cookie 与会话守住边界

浏览器持有 cookie,服务器持有 cookie 所代表的状态。把两者弄混——或让 cookie 承载超出它该有的含义——是认证 bug 最常见的起点。 Cookie 是标识符,不是权威 会话 Cookie 通常是一段由服务器生成的随机标识,服务器在服务端存下它,之后用它反查对应的会话。它只承担一件事:识别会话,真正的权威存放在服务器这一侧。之所以要这样,是因为浏览器能看见并在每次请求中重新发送这段 Cookie——如果你把数据直接塞进 Cookie 里(用户 ID、角色、邮箱),就等于把任何一个可以用它伪造或复用状态的值都交到了客户端手里。保持 Cookie 值只是一长串随机的、客户端不该去读的字符串,让服务器去决定它背后对应的是哪个会话、哪些权限。 诚实定义各属性 每个 Cookie 属性都是一道设定的边界,要刻意设置,而不是交给浏览器默认决定。Secure 让 Cookie 只在 HTTPS 下传输,避免明文泄漏;HttpOnly 禁止 JavaScript 通过 document.cookie 读取,挡住了最常见的 XSS 偷取路径;SameSite 规定跨站请求是否携带——多数站点用 Lax,要求绝不让 Cookie 跟随跨来源请求的用 Strict;Max-Age/Expires 则给它的寿命设定上限。登录后与提权后都应对会话做旋转,而不是让一口气活很久的令牌留在浏览器里。每个属性都要明确声明,因为浏览器替你选的默认值,不会是你真正想选的配置。 会话是服务器的义务 Cookie 只是客户端;会话才是服务端的事,而它必须回答:会话寿命多长、空闲多久算过期、什么操作会作废它。把会话绑定到真实语义上:绝对过期时间,不能让一个会话永远存活;空闲超时,让被丢弃的标签页不能数周后仍能恢复;登出时要服务端把会话删除或标记无效,让浏览器里的令牌变成一把失效的钥匙,而不是一张常驻的通行证。因为令牌仍可能被重放,登录与提权后要旋转会话 token。当 Cookie 保持不可读、服务器又强制过期时,这对「令牌+会话」才是一份小而诚实的契约,而不是一颗定时炸弹。 ...

一月 12, 2027 · 3 分钟 · XB Field Notes
一个令牌从身份提供方传向一个应用。

单点登录是中继,不是密码

单点登录是一场中继,而不是一次口令移交。用户只在身份提供方那里证明一次身份,由提供方交给每个应用一份带签名的断言或令牌。应用从不保存密码,它保存的是提供方的公钥、它期望的签发者,以及它自己的受众标识。 这份分责正是整个设计的要点,而它做的是搬移风险,不是消除风险。应用的安全性从此完全落在若干校验步骤上,而这些步骤很容易被无意跳过;令牌本身,则成了真正值得去偷的东西。 三方,两种编码 SAML 与 OIDC 是同一条中继的两种编码:一种是带 XML-DSig 签名的 XML 断言,一种是带 JWS 签名的 JSON Web Token。两者都携带签发者、主题、受众与过期时间,也都要拿提供方公布的元数据去验——一份签名证书,或一个 JWKS 端点。OIDC Core 对其中两项校验说得很死:从发现文档拿到的签发者标识必须与 iss 声明完全一致,而只要受众里没有列出该客户端,这个令牌就必须被拒绝。 受众校验正是让中继不至于变成万能钥匙的那一步。一枚为分析看板签发的令牌是合法且签名正确的令牌,而计费应用仍然必须拒绝它。这就是 aud 不是形式主义的原因:缺了它,任何信任同一个提供方的应用,都能被一枚为别的应用签发的令牌走进去。 校验是四件事,不是一件 签名只是第一件,而且必须由应用自己选定的算法、对收到的原始字节做验签,绝不能听令牌里说用什么算法。RFC 8725 把两种经典失效都写了下来:攻击者把 alg 改成 none,让某些库干脆跳过验签;或者把 RS256 改成 HS256,让某些库拿 RSA 公钥当共享密钥去做 HMAC。本文这次实测里,一枚无签名令牌和一枚用公钥做 HMAC 密钥的令牌,都被「从头部读 alg」的验证器接受了,也都被「在配置里钉死 RS256」的验证器拒绝了。 ...

十一月 17, 2026 · 1 分钟 · XB Field Notes
三个相连的方块,依次标注观察、决策与行动。

让可复用访问令牌远离请求地址

可复用访问令牌会把某种约定访问能力交给持有它的人。把令牌放进地址,会让这项能力进入平时就会复制地址的系统,例如访问日志、监控记录、浏览器导航历史、书签和分享链接。具体暴露范围取决于请求及策略,但查询参数的名称看起来像秘密,并不会自动让它受到保护。传输位置本身就是凭据披露边界的一部分,应当结合它会经过哪些记录和分享环节判断。 对于 OAuth 持有者令牌请求,通常应采用授权请求头机制。HTTPS 仍然不可缺少,因为把值从查询移动到请求头并不会加密它;TLS 保护的是到终止位置为止的传输数据。传输结束后,应用和中间组件仍然能够检查字段。请求头的直接收益,是让令牌离开请求地址,而不是保证它永远不会被日志或诊断工具捕获。不能把两种不同的保护作用混为一谈。 分清减少暴露与绝对保密 某些访问日志只记录方法、请求目标和状态。在这种格式里,请求头令牌不会出现在目标字段中,而查询令牌会出现。但其他系统可能采集请求头,因此授权字段仍需在日志、链路和错误报告配置中被明确处理。应验证真实管线究竟保存了什么,不能假定请求头可以绕过全部日志层。检查时使用无效的合成值即可,不应为了演示而把真正凭据再次写入更多记录。 引用来源策略能够减少源地址向后续目标披露,却不会删除已经发给目的地的查询内容,也不会抹去浏览器记录或改写已复制的链接。同样,禁止缓存响应并不是要求清除全部访问日志和导航历史的通用指令。把令牌移到片段,会改变 HTTP 请求发送哪些内容,但值仍可被页面脚本和浏览器地址状态访问,也不是适合所有场景的凭据存储方案。 坏掉的样子 团队可能把接口认证从查询参数改成请求头,却让旧的带令牌地址继续留在报告和共享书签中。传输方式改变并不会撤销此前暴露,需要调查受影响的副本,并在系统支持时使已经暴露的可复用凭据失效。另一种误判是把有效期很短的链接称为泄露也无所谓;在有效期间,任何取得这项能力的人仍然可能使用它。缩短时间窗口只能减少风险,不能取消披露的后果。 当实际客户端流程需要时,签名地址可以是有意设计的、范围很窄的资源访问方式,但允许访问的资源、操作、期限和处理规则都应明确审阅。到期失效与只能使用一次并不相同,要阻止到期之前重复使用,需要真正强制执行消费状态的机制。简单的回调接口或浏览器导航,也不会自动构成把无限制可复用接口凭据放进查询的理由,应根据真实客户端选择合适的认证流程。 快速验证 保存代码为 token-placement.py,执行 python3 token-placement.py –serve,打开打印出的页面。本次使用 Python 3.14.4 和 Chrome 152,全部演示值都是明确无效的固定标记,不能替换为真实凭据。回环服务只是观察实验,不是认证服务;它记录请求目标以及示例授权头是否存在,不记录该请求头的值。结束后按控制键加 C 关闭服务,临时事件仅留在本次进程的内存中。 import json import sys from http.server import BaseHTTPRequestHandler, HTTPServer from threading import Event, Thread from urllib.parse import urlsplit from urllib.request import Request, urlopen # Synthetic demonstration value only; this server is not an authentication service. demo = "DEMO-NOT-A-SECRET" events = [] class Handler(BaseHTTPRequestHandler): def log_message(self, *args): pass def do_GET(self): path = urlsplit(self.path).path if path == "/events": body = json.dumps(events).encode() kind = "application/json" elif path == "/echo": entry = {"request_target": self.path, "authorization_present": self.headers.get("Authorization") == "Bearer " + demo, "referer": self.headers.get("Referer")} events.append(entry) body = json.dumps(entry).encode() kind = "application/json" else: events.append({"request_target": self.path, "document": True}) body = ("""<!doctype html><meta charset="utf-8"><title>Token placement lab</title> <h1>A clean referrer does not erase the request URL</h1><pre id="result">Running...</pre><script> const demo=""" + json.dumps(demo) + """; async function run() { const query=await (await fetch('/echo?access_token='+demo)).json(); const header=await (await fetch('/echo',{headers:{Authorization:'Bearer '+demo}})).json(); await new Promise(resolve=>requestAnimationFrame(resolve)); const resourceUrls=performance.getEntriesByType('resource').map(entry=>entry.name).filter(name=>name.includes('/echo')); window.labResult={page:location.href,query,header,resourceUrls}; document.getElementById('result').textContent=JSON.stringify(window.labResult,null,2); } run().catch(error=>{window.labError=String(error);}); </script>""").encode() kind = "text/html; charset=utf-8" self.send_response(200) self.send_header("Content-Type", kind) self.send_header("Content-Length", str(len(body))) self.send_header("Referrer-Policy", "no-referrer") self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(body) server = HTTPServer(("127.0.0.1", 0), Handler) thread = Thread(target=server.serve_forever, daemon=True) thread.start() origin = f"http://127.0.0.1:{server.server_port}" try: with urlopen(origin + "/echo?access_token=" + demo, timeout=5) as response: query = json.load(response) with urlopen(Request(origin + "/echo", headers={"Authorization": "Bearer " + demo}), timeout=5) as response: header = json.load(response) assert demo in query["request_target"] and query["authorization_present"] is False assert demo not in header["request_target"] and header["authorization_present"] is True events.clear() print(json.dumps({"app_url": origin + "/?access_token=" + demo, "wire_placement_checks": "PASS", "real_credentials_used": False}), flush=True) if "--serve" in sys.argv: Event().wait() except KeyboardInterrupt: pass finally: server.shutdown() thread.join(timeout=5) server.server_close() 页面先通过 access_token 查询发出一次请求,再通过授权请求头发出另一次请求。因为页面设置了不发送引用来源,两次接收结果中的 Referer 都为空。然而查询请求的目标仍然包含演示标记,请求头方案的目标则只是 echo 路径。浏览器资源计时条目中的地址也体现同样差异。页面自身的地址同样带有演示标记,因此当前导航记录仍保留这条完整地址,不能因为后续请求没有来源头就认为它消失了。 ...

十一月 1, 2026 · 2 分钟 · XB Field Notes