OAuth 登录,不再掉进令牌乱麻

把 Session Cookie、JWT、OAuth 和 OpenID Connect 当成一回事,网站登录就会迅速变乱。它们并不是同义词。回调反复跳转、Cookie 莫名消失,或者明明拿到了令牌却仍收到 401 时,我会用下面这套模型排查。

🎙️ 发布并录制于: · 更新于 ·

01Session 与 JWT:先选朴素方案

普通网站,我会先把一个不透明的 Session ID 放进 HttpOnly Cookie。Session 存在服务器上,需要时可以立刻撤销。JWT 是一组带签名的声明,适合多个服务在不共享 Session 存储的情况下,校验同一份短期凭据。它不是高级版 Session Cookie。

# 不透明的 Session Cookie:浏览器无法理解其含义
Set-Cookie: __Host-session=s%3A8f1...; Path=/; Secure; HttpOnly; SameSite=Lax

# JWT payload 可读,并未加密
{"sub":"user_42","aud":"api","exp":1784905200}
我的默认选择
一个 Web 应用配一个后端,就用服务端 Session。所谓“无状态”的 JWT 注销,往往最后还是要加撤销列表,等于用更难维护的方式重新造出了状态。绝不要把秘密写进 JWT payload;base64 不是加密。

02OAuth 的四个角色

OAuth 做的是委托授权。资源所有者是用户,客户端是你的应用,授权服务器负责询问用户是否同意并签发令牌,资源服务器则是接受令牌的 API。“客户端”不等于浏览器,授权服务器也不一定托管 API。

resource owner:       the person
client:               your photo-printing site
authorization server: accounts.example
resource server:      photos API
scope:                 permission such as photos.read

Scope 描述的是客户端可以做什么,不是这个用户可以对所有数据做什么。即使令牌带有 photos.read,也不能读取别人的相册。API 必须同时检查 Scope 和资源归属。

03OAuth 不是身份认证协议

OAuth 不会告诉应用是谁登录了。它只是把某项访问权委托出去。OpenID Connect 才加入身份层,包括 ID Token、UserInfo Endpoint、发现元数据,以及验证身份声明的规则。“使用某某账号登录”应该采用 OIDC,不要靠调用 OAuth 的个人资料 Endpoint 自创一套身份判断。

# Access Token:交给 API;audience 是资源服务器
Authorization: Bearer <access_token>

# ID Token:交给客户端;证明这次登录事件
iss  = https://accounts.example
aud  = your_client_id
sub  = stable-provider-user-id
nonce = value-bound-to-this-login
不要用邮箱标识用户
邮箱可能变更,也可能被重新分配。请保存 issuer + subject 这一对值。信任 ID Token 之前,要验证签名、签发者、Audience、过期时间和 Nonce。能解码出其中的 JSON,不等于验证通过。

04Authorization Code 加 PKCE

现在实用的流程是:先把浏览器送到授权服务器,拿回一个短期、一次性的 Code,再通过后端通道拿它换令牌。PKCE 会把这次兑换绑定到发起流程的应用实例。应用先生成随机 Verifier,把它的哈希作为 Challenge 发出,换令牌时再提交 Verifier,证明自己确实持有原值。

1. app stores code_verifier + state + nonce
2. /authorize?response_type=code&code_challenge=HASH&code_challenge_method=S256
3. callback?code=ONE_TIME_CODE&state=...
4. POST /token with code + code_verifier
5. validate ID token; create your own app session
别再走旧捷径
新的浏览器应用不要使用 Implicit Flow,也不要把 Client Secret 塞进 JavaScript,任何人都能看到它。PKCE 能保护 Public Client,不必假装浏览器守得住秘密。

05Cookie 属性决定登录能否保持

HttpOnly 阻止 JavaScript 读取 Cookie,Secure 限制它只能经由 HTTPS 发送。对网站而言,SameSite=Lax 是稳妥的默认值,同时仍允许顶层导航触发 OAuth 回调。__Host- 前缀要求 Secure、Path=/,并且不能设置 Domain,可防止同级子域替你种下这枚 Cookie。

Set-Cookie: __Host-session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax

This Set-Cookie was blocked because it had the "SameSite=None"
attribute but did not have the "Secure" attribute.
# 生产环境修复:添加 Secure,并通过 HTTPS 提供服务。
# 本地 HTTP 开发优先在 localhost 使用 Lax,不要削弱生产配置。

如果登录看似成功,但下一次请求又成了未登录状态,请在浏览器开发者工具里检查回调响应。确认 Cookie 是否真的写入、是否被拦截、是否绑定到了错误的 Host,以及下一次请求有没有带上它。反复重跑 OAuth,修不好 Domain 或 SameSite 配置。

06刷新令牌是高价值凭据

Access Token 应该短期有效。Refresh Token 能在不要求用户重新登录的情况下换取新令牌,所以价值更高。把它保存在可信后端,或者保护严密的 Cookie 方案里;每次使用都轮换。如果已经轮换过的旧令牌再次出现,就撤销整个令牌家族。

POST /oauth/token
  grant_type=refresh_token
  refresh_token=<secret>
  client_id=<client>

response: access_token + new_refresh_token
保存新的 Refresh Token,并使旧令牌失效
不要无限刷新
除了空闲过期时间,还要给 Session 设置绝对最长寿命。轮换只能限制重放,不能让被盗令牌突然变得无害。重置密码、恢复账号或怀疑凭据被盗时,应撤销服务端 Session 和 Refresh Token 家族。

07CSRF:浏览器太“热心”地发送 Cookie

CSRF 能成立,是因为浏览器可能把你的网站 Cookie 自动附到由别的网站发起的请求上。SameSite 有帮助,但登录流程还需要一个随机的 state,并把它绑定到浏览器 Session。回调时必须逐字比较,而且只能消费一次。State 不是装饰,也不是返回地址。

OAuthCallbackError: state mismatch
# 重试之前先排查:
1. Was state stored before redirect?
2. Did the same browser/session return?
3. Did a proxy change host or scheme, losing the cookie?
4. Was the callback opened twice or state consumed early?

# 修复 Session、Cookie 或代理问题。绝不要跳过 State 验证。
安全的返回路径
如果要记住“登录后去哪里”,只允许站内路径,或者使用明确的白名单。直接接受 next=https://attacker.example 会制造开放重定向,让攻击者能借你的可信登录域名做钓鱼。

08XSS 会改变存储方案的选择

只要 JavaScript 能在页面里运行,就能读取 localStorage 中的令牌,包括被入侵的依赖或注入脚本。HttpOnly Cookie 不让 JavaScript 看到凭据,但页面被控制期间,恶意脚本仍可借浏览器发请求。不存在一种存储技巧,能让 XSS 变得可以接受。

# 网站登录不要默认这样做
localStorage.setItem("access_token", token)

# 优先使用 Backend for Frontend Session
browser --HttpOnly session cookie--> your backend
backend --access token--> provider/API
实际可行的防线
转义不可信输出,清理确实需要展示的 HTML,避免内联脚本注入,部署严格的 Content Security Policy,并审计依赖。HttpOnly 能降低令牌被窃取的风险,但清理不了已经失守的页面。

09回调错误:逐字节比较

OAuth 回调失败通常很精确,并不神秘。Provider 会严格比较 Redirect URI,Scheme、Host、Port、Path,甚至末尾斜杠都可能必须与登记值完全一致。此外,Authorization Code 有效期短、只能使用一次,并绑定到 Client、Redirect URI 和 PKCE Verifier。

Error 400: redirect_uri_mismatch
# 实际发送: http://localhost:3000/auth/callback
# 登记值:   http://localhost:3000/auth/callback/
# 修改登记值或生成的 URI,使两者逐字一致。

{"error":"invalid_grant","error_description":"Bad Request"}
# 常见原因:Code 被重复使用或已过期、code_verifier 错误、
# redirect_uri 错误、时钟偏差,或 Code 签发给了另一个 Client。
认真调试一次,不要疯狂重试
记录请求关联 ID、Provider 错误码、Callback URI、Client ID 和时间戳。绝不要记录 Code、Verifier、Token 或 Client Secret。Code 过期时重新开始流程有用;URI 固定不一致时,重试多少次都没用。

10401 令牌排查清单

登录成功后收到 401,并不能证明登录失败。应用可能把 ID Token 发给了 API,或者发的是 Audience 不对、已经过期的 Access Token,甚至什么都没发。先确认资源服务器真正期望什么。即使在调试,也要把令牌内容当作敏感信息。

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token",
  error_description="The audience 'web-client' is invalid"

1. Is the Authorization header present and exactly "Bearer <token>"?
2. Is this an access token, not an ID token?
3. Do iss and aud match this API?
4. Are exp/nbf valid with modest clock skew?
5. Does the API trust the token's signing key and algorithm?
6. If valid but disallowed, that is normally 403: inspect scopes/roles.
最终架构
先用 OIDC 确认身份,再创建应用自己的 Session。除非浏览器确实必须直接调用 Provider,否则把 Provider 的 Access Token 和 Refresh Token 留在服务端。对普通网站来说,朴素 Cookie 加后端,更容易撤销、审计,也更容易在凌晨三点解释清楚。

整套模型就是这样:OAuth 委托访问权,OIDC 提供身份,Cookie 携带应用 Session,每种 Token 都只服务于自己的 Audience。把这些职责混在一起,问题就开始了。

Tell me what missed

A correction is more useful than a compliment. This goes straight to the person who writes SwiftGrasp.

Was this page useful?
0/1000

Please do not include passwords, private keys, or personal information.