稍候片刻,月出文自明。
{ "token": "wH0ewW…", "userId": "OBUBpb7T…", "expiresAt": "2026-11-12", "userAgent": "demo-browser/1.0" }
不提供密钥即可读出载荷 : {"sub":"u_1001","role":"user"}
修改 role 为 admin : 验证失败,签名不匹配
将算法改为 none : 验证失败,服务端只接受指定算法
https://github.com/login/oauth/authorize
?response_type=code
&client_id=Iv1.demoClientId
&redirect_uri=https://shop.example.com/api/auth/callback/github
&scope=read:user user:email
&state=Ha7OiFFgSIc7uXh7e6pr66eQgu3YN9rJ
&code_challenge=msMImne46M5tS111dNj5e3yCklPY6ci0nt-0QNkXI7g
&code_challenge_method=S256
POST https://github.com/login/oauth/access_token
Content-Type: application/x-www-form-urlencoded
Accept: application/json
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https://shop.example.com/api/auth/callback/github
&client_id=Iv1.demoClientId
&client_secret=<服务端环境变量>
&code_verifier=<PKCE 验证值>
{ "iss": "https://accounts.google.com",
"aud": "client_id",
"sub": "10769150350006150715113082367",
"exp": 1353604926,
"nonce": "0394852-3190485-2490358",
"email": "jsmith@example.com", "email_verified": true }
<form action="https://shop.example.com/api/address/default" method="POST">
<input name="address" value="攻击者地址">
</form>
<script>document.forms[0].submit()</script>
无 PKCE,攻击者携带 client_id 与 redirect_uri -> 获得令牌
有 PKCE,攻击者持有 code 但无 code_verifier -> invalid_grant
有 PKCE,攻击者猜测 code_verifier -> invalid_grant
有 PKCE,正规应用提交正确 code_verifier -> 成功
同一 code 再次兑换 -> invalid_grant
用户在购物网站上点“使用 GitHub 登录”,浏览器跳到 GitHub,授权后跳回,页面已经处于登录状态。整个过程中,购物网站没有接触过用户的 GitHub 密码。它凭这次跳转认定用户身份,并在此后的每个请求中继续认定同一用户。
登录状态有两种保存方式:服务端会话与自包含令牌。第三方登录在两者之上增加授权流程,每个参数都对应一类防护。叙述顺序是登录状态、OAuth 与 OIDC 的流程、链路上的攻击与防护、不同客户端的接入,最后是统一架构。文中用三种措辞区分依据:规范要求指 RFC 的 MUST/MUST NOT,安全建议指 SHOULD/RECOMMENDED,工程选择指框架或架构层面的取舍。
文中的响应与实验数据来自本地测试环境。GitHub 与 Google 的服务端使用桩替代,涉及它们自身行为的部分以官方文档为准,未做实测。
Session:登录状态存在服务端
登录要解决两个问题:确认密码正确,以及让后续请求不必重复提交密码。前者取决于密码如何存储,后者决定登录状态放在哪里。
用户提交邮箱和密码后,服务器先验证密码。数据库保存的不是密码本身,而是密码经过慢哈希算法计算出的结果。Argon2id、scrypt、bcrypt 都属于这一类:计算慢、占用内存,目的是抬高离线爆破的成本。SHA-256 等快速哈希不适用,攻击者可以用显卡高速尝试。每个密码还需要独立的随机盐,否则相同密码会得到相同结果。
OWASP 给出的下限是 Argon2id 使用 19 MiB 内存、两次迭代、并行度一;没有 Argon2id 时用 scrypt;遗留系统用 bcrypt,工作因子不低于 10。常见实现选择 scrypt,参数为 N=16384、r=16、p=1,存储格式是 盐:哈希。这组参数低于 OWASP 建议的最低值,对强度有更高要求时应调整。
密码验证通过后,服务器生成随机会话 ID,在 session 表中写入一行,记录用户、过期时间、登录 IP 与设备,再通过 Set-Cookie 交给浏览器。此后浏览器自动携带该 Cookie,服务器按 ID 查表,得到当前用户。
Cookie 中保存的是带签名的 Session ID,格式为 sessionId.signature。签名由服务端密钥计算,用于检测篡改;sessionId 仍是随机标识,用于查询服务端会话记录。它不是 JWT,也不是自包含会话:签名不会增加 ID 的随机性,也不能替代查表。
Cookie 属性各自对应一类风险。HttpOnly 阻止页面脚本读取会话 ID,降低 XSS 直接窃取凭据的可能;Secure 限制只经 HTTPS 传输;SameSite=Lax 阻止多数跨站请求携带 Cookie;不设置 Domain 时 Cookie 仅属于当前主机,子域名无法读取;Max-Age 控制有效期,这里是七天。
会话需要存放在多台服务器都能访问的位置。进程内存最快,但重启丢失且无法跨实例共享;数据库可持久保存,代价是每个请求多一次查询;Redis 读写快且自带过期,需要额外运维。多实例部署时,会话存储必须是共享的,否则请求落到不同机器会导致用户掉线。
会话通常默认七天有效,用户活跃时每 24 小时顺延一次。需要绝对过期上限的场景要自行限制。注销会删除当前会话行并清除 Cookie,旧 Cookie 随即失效。多设备登录表现为多行会话记录,可以逐条或批量撤销。
JWT:登录状态放进令牌
Session 的每次请求都要查询存储,在多服务部署或无法共享存储的场景下成本较高。另一种做法把状态放进凭据本身。服务器将用户 ID、角色、过期时间写成 JSON,用密钥签名后编码成字符串,客户端保存并在请求中携带。服务器验证签名即可读取内容,不需要查询存储。
JWT 由三段组成,以点分隔:头部声明算法,中间是载荷,末尾是签名。载荷只做 base64url 编码,不加密,持有者可以直接解码读取。
签名保证内容未被修改。由此产生三条要求:敏感信息不应放入载荷;服务端固定允许的算法,不采信令牌头部声明的 alg;iss、aud、exp 必须校验(RFC 8725)。
JWT 的代价在于撤销。签发后到过期前,令牌始终有效,改密码、封号或令牌泄露都无法立即终止。可行手段只有缩短有效期,或在服务端维护黑名单,而后者让每次校验重新依赖状态存储。实际系统常用两层结构缓解:Access Token 短命,撤销靠停止签发;长期登录依赖可撤销的 Refresh Token,刷新时校验其状态。状态仍留在服务端,只是检查频率远低于每个请求。
服务端 Session
JWT
凭据内容
随机 ID
签名的载荷
验证方式
查询存储
验证签名
立即撤销
支持
不支持
多服务共享
共享存储
共享验签公钥
典型场景
同源 Web、BFF
服务间短期授权
浏览器中的存放位置同样影响风险。localStorage 可被同源脚本读取,一旦出现 XSS,令牌可被定时发送到外部;HttpOnly Cookie 不允许脚本读取凭据,虽然无法阻止脚本借浏览器发起请求,但凭据本身不会外泄。IETF 的浏览器应用草案将长期令牌存入 localStorage 视为高风险做法。
到这里,登录状态的两条路线清楚了:服务端会话以查表换撤销能力,JWT 以自包含换免查表,代价是撤销困难。两者并不互斥,常见组合是短命访问令牌加可撤销的长期会话。
第三方登录:OAuth 2.0 授权码流程
前两节讨论的是用户在本站注册并保存密码的情况。若站点希望用户直接用已有账号登录,又不愿经手第三方密码,就需要一套委托验证流程。OAuth 2.0 定义了这个过程。
直接让用户把第三方密码交给站点并不可行,RFC 6749 §1 给出三点原因:站点需要经手或保存该密码,一旦被攻破会波及第三方账号;站点获得的是账号的全部权限,而不是身份信息;用户只能通过改密码收回授权,所有使用该密码的应用同时失效。
OAuth 将密码保留在第三方页面,站点获得一个范围受限、可撤销的授权。流程涉及四个角色。
角色
本例对应
职责
资源所有者
用户
在 GitHub 页面决定是否授权
客户端
购物网站后端
需要确认用户身份
授权服务器
GitHub 授权与令牌接口
验证用户并签发凭据
资源服务器
api.github.com
按凭据返回用户资料
在典型的服务端授权码流程中,浏览器只接触 code,access_token 始终在服务器与 GitHub 之间传递。
授权请求
流程第一步是让浏览器携带参数跳转到授权服务器。参数决定了后续每个环节能否成立,也决定了攻击者能否介入。
一个典型的授权地址如下,client_id 为演示值:
参数
作用
缺失或错误的后果
response_type=code
声明使用授权码流程
无法进入后续兑换
client_id
标识发起请求的应用
授权服务器无法识别应用;该值公开,不能用于身份认证
redirect_uri
指定授权结果的接收地址
授权码可能被送到其他地址
scope
申请权限范围
权限过大或后续接口无法读取
state
绑定回调与发起请求的浏览器
无法防御 CSRF
code_challenge
PKCE 的哈希值
公开客户端的授权码可被冒领
授权服务器完成验证后,将浏览器重定向到 redirect_uri,并附加两个参数:
code 一次性有效,GitHub 文档标明十分钟过期,并绑定发起请求的 client_id 与 redirect_uri。它不能直接访问资源。
分两步走的原因在于传输通道不同。授权码经浏览器地址栏传递,会出现在历史记录、访问日志和 Referer 中;access_token 能直接访问资源,不能经过这条路径。因此授权码被设计为短命、一次性,真正有价值的凭据只在服务端与授权服务器之间传递。
令牌兑换
回调收到 code 后,后端需要用它换取访问令牌。这一步在服务端完成,浏览器不参与;是否携带密钥,也是区分机密客户端与公开客户端的依据。
兑换请求由后端直接发往 GitHub,不经过浏览器:
client_secret 证明请求来自注册过的后端,必须保存在服务端。GitHub 要求将凭据放在表单字段中,而 RFC 6749 §2.3.1 更推荐使用 Authorization: Basic 头,表单方式属于兼容做法。GitHub 默认返回表单编码结果,请求头带 Accept: application/json 才返回 JSON。
用户信息与本地账号
令牌只证明授权成功,站点还需要确认对方身份,并对应到本地账号。
拿到 access_token 后,后端请求 GET https://api.github.com/user,请求头为 Authorization: Bearer <token>。响应中的数字 id 稳定不变,login 可被用户修改,email 可能为空,需要再请求 /user/emails 获取主邮箱及验证状态。
关联本地账号时,唯一标识必须使用 id,不能使用邮箱或用户名。account 表以 providerId=github 与 accountId=583231 作为联合键。
按邮箱自动合并等价于信任第三方对邮箱所有权的判断,存在账号接管风险。第三方邮箱验证只说明该邮箱属于这个第三方账号,不等于本地账号的所有权已经验证。常见实现要求两侧邮箱都已验证才允许合并;本地测试中,本地账号邮箱未验证时登录被拒绝。
站点随后建立自己的会话。GitHub 的 access_token 只保存在服务端供后续调用 API,不是站点的登录凭据。下文以 OAuth App 为例,GitHub App 使用细粒度权限,参数含义不同。
OIDC:OAuth 之上的身份认证
OAuth 只定义如何获取访问资源的令牌,没有规定如何告知客户端用户身份,接入多家提供商需要分别适配。
OpenID Connect 在 OAuth 上补充了身份层:授权请求增加 scope=openid,令牌接口在 access_token 之外返回 id_token。后者是 JWT,内容为本次认证结果。
Access Token
ID Token
使用方
资源服务器
客户端
内容
访问凭据,对客户端不透明
签名的 JWT,声明用户与认证信息
能否证明用户身份
不能
校验后可以
Google 文档中的 ID Token 载荷示例:
校验要求见 OIDC Core §3.1.3.7,每项对应一种冒充方式:
OIDC Core §3.1.3.7 允许一种替代:当 ID Token 由客户端与令牌端点直接通信、并经正确验证的 TLS 连接获得时,可以用 TLS 的服务端身份校验代替签名校验。这不等于可以信任解码后的内容,iss、aud、exp 等声明仍然必须校验,这份信任也不能传递给其他组件。经客户端转交或从其他渠道取得的 ID Token,必须完整验证签名与声明。实现上通常按来源区分这两条路径。
用户唯一标识应使用 iss 与 sub 的组合。OIDC Core §5.7 明确说明邮箱与昵称不能作为唯一标识,发行者可以将同一邮箱先后分配给不同用户。
access_token 不能作为身份证明。它是持有者凭据,只说明访问权限,不说明签发给哪个应用。RFC 6749 §10.16 描述了对应攻击:恶意应用诱导用户授权取得令牌,再提交给另一站点声称代表该用户;若站点仅检查令牌有效性,即被冒充。ID Token 的 aud 明确了签发对象,这是它可以承担身份认证的前提。
GitHub 的情况不同:它不实现 OpenID Connect,也不签发 ID Token,身份只能通过 /user 与 /user/emails 获取。
OIDC 把“用户是谁”变成一条可校验的声明,但声明的可信度取决于校验是否完整:签名、发行者、受众、有效期、nonce 缺一不可。缺任何一项,令牌都可能被用于冒充。
redirect_uri 与回调地址
回调是整条链路中最容易配置错误的部分。整条流程涉及三个不同地址,它们分属不同角色,校验方也不同:
前两个是同一地址的两个视角,第三个与之无关,可以是站内任意受信任页面。
GitHub 只校验第一个。默认要求与登记值完全一致,目的是防止攻击者替换授权链接中的回调地址,把授权码引向自己的服务器(RFC 6749 §10.6)。RFC 9700 §2.1 要求授权服务器使用精确字符串匹配。
GitHub 提供可选的通配匹配:主机与端口必须一致,路径可以是回调路径的子目录,子域名可以不同(Authorizing OAuth apps,2026-11-06 核对)。文档说明,2026 年 8 月 3 日之前只配置单个回调地址的应用会自动启用该模式以保持旧行为。通配范围内的开放重定向会导致授权码泄露,旧应用需要确认该设置。
第三个地址由站点自行校验:登录后的跳转目标必须属于可信域。本地测试中,相对路径 /account 通过,https://evil.example/steal 与 //evil.example 均被拒绝。
该校验针对开放重定向。若登录后的跳转地址可任意指定,攻击者构造 https://shop.example.com/login?next=https://evil.example/,用户完成登录后被引导至仿冒页面;若该跳转位于 OAuth 流程内,授权码会一并泄露(RFC 9700 §4.11.1)。如需登录后返回原页面,应将目标路径作为 callbackURL 传递,由服务端校验并保存,而不是拼入 redirect_uri。
CSRF:原理与常见防护
前面的 Web 方案都依赖 Cookie 识别用户。在讨论 OAuth 回调之前,需要先明确浏览器在什么情况下会自动携带 Cookie,以及这会带来什么后果。
浏览器是否携带 Cookie 只取决于请求目标。RFC 6265 §8.2 称之为环境权限:任何能促使浏览器向目标站点发请求的页面,都能让浏览器附带该站点的 Cookie,即使无法读取内容。
用户已登录购物网站,随后打开攻击者页面。页面中只有一个自动提交的表单:
浏览器向 shop.example.com 发送请求时自动附带 Cookie。服务端看到有效会话,执行了修改。攻击者不需要知道 Cookie 的值。
方法
机制
局限
CSRF Token
服务端按会话生成随机值,提交时比对
需要服务端状态,每个提交点都要携带
Double Submit Cookie
同一随机值放 Cookie 与请求头,服务端比对
朴素实现可被写 Cookie 绕过,OWASP 建议签名并绑定会话
Origin / Referer 校验
检查请求来源是否可信
部分请求不携带这些头
SameSite
跨站请求不携带 Cookie
Lax 放行顶层 GET 导航;按可注册域名判定同站;浏览器默认值不一致
Fetch Metadata
读取 Sec-Fetch-Site 判断来源
旧浏览器需要 Origin 校验兜底
这些方法不能互相替代。常用组合是 SameSite=Lax 加 Origin 校验,并避免用 GET 执行状态变更。对携带 Cookie 的非 GET 请求应校验 Origin 是否属于可信域。要求请求体为 application/json 可以挡住 HTML 表单,但这是辅助措施,不能替代 Origin 校验或 CSRF token。
登录动作本身也可能被攻击。Login CSRF 让受害者的浏览器以攻击者账号登录,受害者后续输入的地址、支付信息会存入攻击者账号。对邮箱登录接口,常见做法是读取 Fetch Metadata:本地测试中,跨站页面导航被拒绝,同源请求通过,不可信 Origin 被拒绝,完全没有浏览器头的请求被放行。最后一项是因为没有来源头的客户端不属于浏览器自动携带 Cookie 的威胁模型。
state 与 PKCE:防 CSRF 与授权码注入
上一节的 CSRF 发生在站内接口,后果是执行一次非预期操作。OAuth 把同类问题带到回调地址上,后果更严重:受害者可能被登录成攻击者的账号。state 与 PKCE 都是为这个场景设计的。
OAuth 回调地址是公开的 GET 链接,例如 https://shop.example.com/api/auth/callback/github?code=…&state=…。任何页面都能让浏览器访问该地址。因此回调处理程序在收到 code 时,必须先确认这次授权由当前浏览器发起。缺少这一确认会产生两类问题。
Login CSRF
第一种情况的目标是让受害者在不知情时使用攻击者的账号。攻击者使用自己的 GitHub 账号完成授权,在浏览器被重定向回站点之前拦截该跳转。此时攻击者持有一个有效且未使用的 code,以及站点签发的 state。随后将这条回调链接发送给受害者,或嵌入自己页面的图片地址中。
整个攻击不需要窃取任何凭据:code 由攻击者自行授权获得,state 是站点签发的合法值,攻击者也不掌握受害者的会话。受害者浏览器缺少的,只是攻击者那次授权留下的 Cookie。
账号绑定
第二种情况针对已经登录的受害者,目标是让攻击者的第三方身份进入受害者账号。攻击者用同样方式取得自己的 code,让受害者访问回调链接。受害者的会话 Cookie 被自动携带。若回调逻辑是“取得第三方身份后绑定到当前登录用户”,攻击者的 GitHub 身份即被绑定到受害者账号,此后攻击者可凭自己的 GitHub 登录受害者账号。
state 的作用
两类攻击都源于同一个缺口:回调无法确认授权由谁发起。state 补上的就是这项确认。
站点在发起授权时生成随机值,一份存入服务端记录,一份写入签名 Cookie。授权请求携带该值,GitHub 原样返回。回调到达时,服务端依次检查:记录存在、未过期、Cookie 中的值与 URL 中的值一致。三项通过后继续处理,并立即删除记录与 Cookie。
在 Login CSRF 中,state 对应的 Cookie 位于攻击者浏览器,受害者浏览器没有该 Cookie,第三项校验失败。本地测试结果为 302 → /api/auth/error?error=state_mismatch,未下发会话。
防护的关键不是随机性,而是绑定关系:该字符串同时存在于发起授权的浏览器和服务端。攻击者可以生成任意合法的 state,但无法把匹配的 Cookie 写入受害者浏览器。例外是子域名被控制,或站点运行在明文 HTTP 上。
典型实现把 state 附带的数据(回调地址、PKCE 的 code_verifier 等)存入服务端,浏览器只保存签名 Cookie,两者有效期都是数分钟。校验通过后记录与 Cookie 均被删除,同一条回调链接只能使用一次;重复请求会返回 state 不匹配。
PKCE 的作用
state 确认回调是否由当前浏览器发起。PKCE(RFC 7636)回答另一个问题:兑换授权码的一方,是否就是发起授权的一方。
客户端在每次授权时生成随机串 code_verifier,计算其 SHA-256 哈希作为 code_challenge:
授权请求只携带 code_challenge,授权服务器将其与即将签发的 code 绑定。兑换时客户端提交 code_verifier,授权服务器重新计算哈希并与绑定的值比较,不一致则返回 invalid_grant。
授权阶段只发送哈希值,是因为该请求经浏览器地址栏传递,可能被读取;SHA-256 不可逆,无法从中还原 code_verifier。code_verifier 只在兑换阶段出现,且由客户端直连令牌接口,不经过浏览器。因此仅持有 code 的一方无法完成兑换。RFC 7636 附录 B 给出的测试向量可用于核对实现:dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk 的哈希为 E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM。
公开客户端与 PKCE
PKCE 的必要性在公开客户端上最明显,这类客户端无法保存任何密钥。服务端 Web 应用属于机密客户端,client_secret 保存在服务器,授权码泄露后仍无法兑换。SPA、移动应用、桌面应用与 CLI 属于公开客户端,代码运行在用户设备上,可以被拆包与抓包,任何预置密钥都可被提取。RFC 8252 §8.5 明确指出,随应用分发的静态密钥不应视为机密;混淆与加密只能提高提取成本,应用运行时必须使用该值,攻击者即可在运行时获取。client_id 本身是公开标识,不能用于身份认证。
典型攻击:恶意应用注册与正规应用相同的自定义 URL scheme。用户发起授权后,系统将带回 code 的回调地址交给恶意应用。恶意应用从安装包中读取 client_id 与 redirect_uri,直接向令牌接口兑换。没有 PKCE 时,令牌接口没有拒绝的依据。
用一个简化的授权服务器验证该过程:
PKCE 的作用是取消对预置长期密钥的依赖,改为每次授权生成只存在于发起进程中的短期密钥。RFC 8252 §6 要求公开原生客户端实现 PKCE,RFC 9700 §2.1.1 建议所有客户端使用。
PKCE 能否替代 state
两个机制在防 Login CSRF 上功能重叠,接入时会遇到选择问题:能否只保留一个。答案取决于授权服务器的行为,RFC 9700 对此有明确表述:
原因在于兑换环节。受害者浏览器被引导至攻击者构造的回调链接后,客户端使用受害者会话中的 code_verifier 兑换攻击者的 code,而该 code 绑定的是攻击者那次授权的 code_challenge,校验失败,令牌不会签发。
结论:仅就防 Login CSRF 而言,PKCE 可以承担 state 的作用,但需要同时满足四个条件:授权服务器支持并强制校验 PKCE;每次授权重新生成 verifier;verifier 与本次授权事务、发起它的客户端与用户代理绑定;回调无法通过攻击者提供的参数取得其他事务的 verifier。规范用词是“可以”,不是推荐。
强制执行不可省略,原因见 RFC 9700 §4.8 描述的降级攻击。该攻击有两个前提:授权服务器允许无 PKCE 的请求,且客户端无法正确判断授权响应与原始授权事务的对应关系,通常是没有使用 state,或 state 校验存在缺陷。满足前提时,攻击者可在自己的授权请求中删除 code_challenge,取得未绑定 challenge 的 code,再引导受害者使用该 code 兑换。客户端提交受害者的 code_verifier,授权服务器因该 code 未绑定 challenge 而跳过校验,签发属于攻击者的令牌。
对策在 §4.8.2:授权服务器必须拒绝“授权请求没有 code_challenge、兑换请求却携带 code_verifier”的组合。该检查位于服务器端,客户端无法执行,也无法验证。
模拟结果:授权服务器不强制 PKCE 时,攻击成功并签发攻击者令牌;强制时返回 invalid_grant。四种配置对 Login CSRF 的效果如下。
配置
能否防御
原因
仅 state
能
依赖 state 与浏览器绑定
仅 PKCE,服务器强制
能
verifier 位于发起授权的浏览器会话
仅 PKCE,服务器不强制
不能
可被降级绕过
state 与 PKCE 并用
能
任一环节失效时另一层仍有效
还有一项实现条件。若服务端只把 code_verifier 存进数据库、按回调中的 state 查找,而不校验浏览器 Cookie,重放 Login CSRF 会成功:本地测试中跳过 Cookie 校验后,受害者获得了攻击者账号的会话,因为攻击者的 state 在数据库中有记录,其中的 verifier 也随之可用。
场景(授权服务器为桩实现)
结果
校验 state 与浏览器 Cookie
请求被拒绝,无会话下发
跳过浏览器 Cookie 校验
受害者获得攻击者账号的会话
state 数据整体存入加密 Cookie,同时跳过校验
请求被拒绝,无会话下发
最后一行是因为 cookie 策略把完整 state 数据加密存入 Cookie,回调时从 Cookie 读取;受害者浏览器没有该 Cookie,无法取得数据。
因此,“仅靠 PKCE”要求 code_verifier 位于发起授权的浏览器会话中,不能只存于服务端、凭回调参数即可查询。RFC 9700 §2.1.1 的表述是“与客户端及发起事务的用户代理安全绑定”。跳过 Cookie 校验通常只适用于开发或预发环境。
实际系统通常两者都用。兼容性是一方面:要照顾不强制 PKCE 的提供商,state 还是 RFC 6749 的标准机制;业务上也有需要:state 能携带登录后跳转地址、账号绑定目标等信息,PKCE 无法承担。更关键的是绑定对象不同——state 绑定浏览器会话,PKCE 绑定授权事务的持有者;前者防别人替你发起授权,后者防别人替你兑换授权码。前提是 verifier 的存储位置不会绕开浏览器绑定。
SPA、原生 App 与 CLI 怎么接入
前面的流程默认客户端是服务端应用。浏览器脚本、移动应用与 CLI 无法保存密钥,属于公开客户端(RFC 6749 §2.1;RFC 8252 §8.4 要求原生应用按公开客户端注册),密钥与回调方式都要相应改变。
客户端
发起方
密钥
授权码返回方式
防护
服务端 Web
后端
client_secret 存服务端
回调至后端路由
state,建议加 PKCE
SPA
浏览器脚本
无
返回前端路由
必须 PKCE
移动 / 桌面应用
应用调起系统浏览器
无
应用链接跳回
必须 PKCE
CLI
CLI 调起浏览器
无
本地临时服务或设备码
必须 PKCE
SPA 在后端协助下可以接近机密客户端:由同源后端完成 OAuth 并保管令牌,浏览器只持有会话 Cookie,即 BFF 模式。IETF 浏览器应用草案将这一模式列为三种架构中安全性最高的一种。若 SPA 直接处理令牌,code_verifier 通常放在 sessionStorage,其生命周期与标签页一致。
原生应用有三项硬性要求。使用系统浏览器或系统提供的浏览器组件,不使用内嵌 WebView(RFC 8252 §8.12,原文为 MUST NOT),原因是宿主应用可以读取 WebView 中的按键、表单与 Cookie;必须使用 PKCE(§6);回调地址在三种方式中选择。
回调方式
示例
特点
声明的 https 链接
https://app.example.com/oauth2redirect
iOS 称 Universal Links,Android 称 App Links,由系统验证域名归属
自定义协议
com.example.app:/oauth2redirect
需使用反向域名;多个应用可注册同一协议,授权码可能被截获
本机回环地址
http://127.0.0.1:{port}/callback
桌面应用常用;授权服务器需允许任意端口
iOS 与 macOS 使用 ASWebAuthenticationSession。Apple 文档说明,系统会提示用户正在向哪个域名认证,并保证只有发起会话的应用收到回调,即使多个应用注册了同一协议。Android 使用 Chrome Custom Tabs,它与浏览器共享登录状态。
CLI 有两种方式。回环回调在 127.0.0.1 启动临时 HTTP 服务,授权完成后浏览器重定向至该地址。RFC 8252 §8.3 与附录 B.5 要求:仅在授权期间监听、只绑定回环接口、使用 127.0.0.1 而非 localhost、Linux 上不设置 SO_REUSEPORT 与 SO_REUSEADDR。GitHub 文档说明回环地址可以使用与登记不同的端口。
设备授权(RFC 8628)适用于无浏览器环境。客户端请求设备码,得到 device_code、user_code、verification_uri 与 interval;用户在另一台设备上打开地址并输入 user_code;客户端按间隔轮询令牌接口。返回值为 authorization_pending(继续等待)、slow_down(间隔增加五秒)等状态,客户端按间隔轮询。RFC 8628 §5.4 指出远程钓鱼风险:攻击者可自行发起设备授权,诱导用户输入其 user_code,授权页应提示用户确认设备。
其他 OAuth 攻击与防御边界
除前面详述的两类攻击外,链路上还有若干问题,触发条件、责任方与防御边界各不相同。
攻击
发生位置
前提
防御
边界
授权码注入
回调
能将攻击者的 code 送入受害者会话
state;PKCE
state 可被能读取授权响应者重放,PKCE 不受影响
授权码截获
应用回调
恶意应用注册同一协议
PKCE
只保护授权码,不保护已签发的令牌
授权码重放
令牌接口
重复使用同一 code
授权服务器拒绝并撤销相关令牌(RFC 6749 §4.1.2、§10.5)
属于服务器责任
redirect_uri 篡改
授权请求
能修改授权链接
授权服务器精确匹配(RFC 6749 §10.6)
GitHub 通配模式会放松该限制
开放重定向
跳转环节
站点存在任意跳转接口
跳转白名单(RFC 9700 §4.11.1)
只能由站点自身修复
会话固定
登录前后
复用登录前的会话标识
登录时更换标识
上述流程每次登录新建会话,不存在该环节
令牌泄露
传输与存储
日志、历史记录、数据库可读
缩短令牌生命周期、加密存储、HttpOnly
第三方令牌常以明文落库,需要显式加密
点击劫持
授权页
授权页可被 iframe 嵌入
授权服务器设置 frame-ancestors(RFC 9700 §4.16)
客户端只能保护自身页面
这些攻击的独立前提可以归为几类:攻击者能让受害者浏览器访问回调、能读取授权响应、地址校验存在缺口、状态存储存在缺口。其余名称是这些前提在不同位置的表现。
实现时的职责划分
一次登录在实现上涉及三方,分清职责才能判断问题该由谁修。
工作
责任方
生成 state 与 verifier、计算 challenge、校验 state 与 Cookie
业务后端
校验 verifier 与 challenge 是否匹配、授权码的一次性与绑定
授权服务器
执行 Secure、HttpOnly、SameSite,自动携带 Cookie
浏览器
签名与 JWT 解析
依赖库
两个容易忽略的细节。回调如果同时接受 POST(例如 form_post 响应模式),应先转为 GET 重定向:跨站 POST 不携带 SameSite=Lax 的 Cookie,跨站顶层 GET 导航会携带;这也说明 state Cookie 不能使用 Strict。转换只解决 Cookie 携带问题,不能代替 state 与 PKCE 校验。另一个是账号合并:按邮箱自动合并能减少重复账号,但把账号安全建立在第三方邮箱验证上,是否启用取决于业务对账号接管的容忍度。
客户端也无法验证授权服务器是否强制执行 PKCE。当提供商只把该参数标为建议时,不应把它当作唯一的 CSRF 防线。
多端认证架构
前端形态不同,身份只有一套:各端凭据由同一个业务后端签发,第三方 OAuth 也只由后端接入。
客户端的选择可以归纳为:Web 用 Cookie 会话;SPA 优先 BFF,后端保管令牌,浏览器只拿会话 Cookie;移动端、桌面端与 CLI 通过系统浏览器完成授权,再向后端换取自己的会话或令牌。作为默认,PKCE 对所有客户端启用;state 保留,用于兼容不强制 PKCE 的提供商与携带业务上下文。
原生端获取业务凭据有两种方式。一种由后端签发一次性授权码,应用携带自己的 code_verifier 换取会话,码只能使用一次;PKCE 在这里保护的是站点自己的码,与第三方无关。另一种由服务端把会话 Cookie 附加在跳回应用协议的 URL 中,应用保存 Cookie;这条路径不经过 PKCE,安全性依赖重定向目标校验与 state Cookie。
令牌生命周期按用途分开:第三方刷新令牌只存服务端,必要时加密;应用与 CLI 持有后端签发、可单独撤销的长期令牌,每台设备一条记录。向公开客户端签发刷新令牌时,按 RFC 9700 §4.14.2 做轮换或发送方约束。注销的即时性取决于凭据形式:删除会话行立即生效,Cookie 缓存或短期 JWT 则要等缓存或令牌过期。
认证与授权分层:认证层只输出用户 ID 与会话;成员关系、角色与权限在独立授权层按请求查询,不写进长期令牌。
结语
认证链路由若干独立环节组成:第三方确认用户完成认证,state 确认回调属于当前浏览器,PKCE 确认兑换方即发起方,会话表让站点在后续请求中识别用户。每个环节单独看都简单,缺少任一层,链路就存在可插入的位置。
文中数据来自本地测试环境。GitHub 与 Google 服务端使用桩替代,其自身行为以官方文档为准,未做实测;移动端与桌面端的实现未在设备上运行。
参考资料
规范:RFC 6749(OAuth 2.0)、RFC 7636(PKCE)、RFC 8252(原生应用 OAuth)、RFC 8628(设备授权)、RFC 9700(OAuth 安全最佳实践,BCP 240)、RFC 6265(Cookie)、RFC 7519 与 RFC 8725(JWT)、OpenID Connect Core 1.0。
草案:draft-ietf-httpbis-rfc6265bis(Cookie 前缀)、draft-ietf-oauth-browser-based-apps(浏览器应用架构)。两者目前均为草案状态。
文档:OWASP CSRF Prevention、Session Management、Password Storage;GitHub 的 Authorizing OAuth apps、Differences between GitHub Apps and OAuth apps、Authentication discovery endpoints;Google Identity 的 OpenID Connect;Apple 的 [ASWebAuthenticationSession](https://developer.apple.com/documentation/authenticationservices/aswebauthenticationsession);Chrome 的 Android Custom Tabs 概览。