12k
All articles

为新 Web 应用选择身份验证策略

2026年 Web 应用认证怎么选:会话或 JWT、OAuth 2.0/OIDC、passkey 和托管服务,并比较失效、CSRF 与 UX。

OpenReplay Team
OpenReplay Team
为新 Web 应用选择身份验证策略

对于 2026 年的新 Web 应用,身份验证策略应与应用架构形态相匹配:服务端渲染的单体应用应首选基于签名 HttpOnly Cookie 的服务端 Session;SPA、移动端客户端或 API 后端应使用短期访问令牌配合存储在 HttpOnly Cookie 中的刷新令牌轮换机制;凡是需要社交登录或企业级 SSO 的场景,应在此基础上叠加 OAuth 2.0/OIDC;如果身份验证本身不是你的核心产品,最稳妥的默认选择是托管身份验证服务商。无论选择哪种基础方案,都应将 Passkey 作为防钓鱼因子加以集成。本文后续内容将针对具体评估标准逐一论证上述每个分支——包括令牌吊销、水平扩展、CSRF/XSS 暴露面、实现复杂度,以及只有在生产环境中才会暴露的用户体验失效模式。

大多数新应用都会陷入同一个误区:因为某个教程用了 JWT,就直接照搬,却没有任何需要无状态扩展的实际理由来支撑这一选择所带来的吊销成本。这是一个需要比较权衡的决策,而非默认选项——因此,以下将从评估标准、对比表格和应用架构到方案推荐的映射矩阵三个维度展开分析。

核心要点

  • Session 与 Token 之争,本质上是认证状态存储位置的问题——在服务端还是在客户端;而 Cookie 与 Authorization 请求头之争,只是决定凭证传输方式的问题。将两者混为一谈,正是大量新应用盲目照搬 JWT 的根本原因。
  • 将 JWT 存储在 localStorage 中现已成为反模式:页面上任何 XSS 漏洞都可以读取并窃取令牌,因此应将凭证保存在 JavaScript 层无法访问的 HttpOnly Cookie 中。
  • JWT 的难点不在于签发,而在于吊销。要实现即时登出,需要配合短期令牌生命周期与刷新令牌轮换机制,或维护一个服务端黑名单——而后者悄然重新引入了 JWT 本来试图消除的服务端状态。
  • OAuth 2.1 目前仍是 IETF 互联网草案(draft-15,2026 年 3 月),尚未成为正式 RFC,但其方向已经明确:强制要求 PKCE、精确匹配重定向 URI,并移除了隐式授权(Implicit Grant)和密码凭证授权(Resource Owner Password Credentials Grant)。
  • 如果身份验证不是你的核心产品,托管身份验证服务商(Auth0、Clerk、Supabase Auth、WorkOS、Stytch)是 2026 年务实的默认选择——你可以直接继承 Session 管理、社交登录、MFA 和 Passkey 能力,而无需自行承担安全漏洞的风险面。

厘清核心概念:状态存储位置 vs. 凭证传输方式

Session 与 Token 之争,是关于认证状态存储位置的问题——在服务端还是在客户端的凭证内部;而 Cookie 与 Authorization 请求头之争,只是决定该凭证如何通过 HTTP 传输的问题。这是两个相互正交的维度,将其混为一个决策,正是众多团队引入自己并不需要的 JWT 的根本原因。这一区分是贯穿本文的核心脉络,身份验证服务商 Authgear 也持相同观点

Session 是有状态的:服务端存储权威记录,并向客户端颁发一个指向该记录的不透明 ID——类似酒店门卡模型,前台保存着主控列表。JWT 是无状态的:声明(Claims)自包含且经过签名,任何服务端无需查询即可验证——类似数字护照模型,任何检查人员只需信任签名,无需致电签发机构。两种方式都可以通过 Cookie Authorization 请求头进行传输。Cookie 和请求头是传输层;Session 和 Token 是架构层。

评估维度服务端 SessionJWT / 自包含令牌
状态存储位置服务端(存储层)客户端(签名令牌)
吊销能力即时——删除记录即可困难——需短期 TTL + 轮换,或黑名单
水平扩展需要共享存储(如 Redis)无状态;无需逐请求查询
凭证体积约 32 字节的不透明 ID每次请求携带 800+ 字节的声明
主要攻击面CSRF(Cookie 自动发送)XSS(从 JS 可读存储中窃取令牌)
最适场景服务端渲染的单体应用API 优先、SPA、移动端、微服务

体积差异一行即可概括无状态与有状态的核心权衡:Session Cookie 仅携带约 32 字节的不透明 ID,需要服务端查询;而 JWT 将声明内联携带——每次请求往往超过 800 字节——但无需查询。本质上是用带宽开销和吊销延迟,换取对存储层的消除。

Session:服务端渲染应用的默认选择

对于服务端渲染的单体应用,应首选基于签名 HttpOnly、Secure、SameSite Cookie 的服务端 Session——这是最简单且正确的选项,并且可以免费获得即时吊销能力。浏览器自动发送 Cookie,无需在 JavaScript 中存储任何认证状态,而注销用户只需在服务端执行一次删除操作。

其工作机制分为四个步骤:用户提交凭证,服务端创建 Session 记录并将其 ID 通过 Cookie 返回,浏览器在每次请求时自动携带该 Cookie,注销时同时销毁两端的状态。在密码存入存储层之前,必须使用 Argon2 进行哈希处理——OWASP 密码存储备忘单将其列为当前首选算法。Session ID 应具备高熵值,且绝不应嵌入敏感数据;OWASP Session 管理备忘单是关于 ID 长度、轮换和超时的权威参考。

# FastAPI: issue an opaque session, store it server-side, set a hardened cookie
import secrets
from fastapi import FastAPI, Response
# redis client + Argon2 password verification omitted for brevity

app = FastAPI()
SESSION_TTL = 60 * 60 * 24  # 24 hours

@app.post("/login")
async def login(response: Response):  # verify Argon2 hash first
    session_id = secrets.token_urlsafe(32)          # ~256 bits of entropy
    await redis.setex(f"sess:{session_id}", SESSION_TTL, user_id)
    response.set_cookie(
        "session", session_id,
        httponly=True,    # JS cannot read it -> blunts XSS exfiltration
        secure=True,      # HTTPS only
        samesite="lax",   # mitigates CSRF on top-level navigations
        max_age=SESSION_TTL,
    )
    return {"ok": True}

这段代码展示了 Session 的核心价值:密钥从不存在于 JavaScript 中,服务端持有唯一的真实来源。随之而来的是两个权衡。第一是扩展性:由于记录存储在服务端,多实例部署需要配置粘性 Session,或者——更好的方案——使用 Redis 等共享存储,使任意实例都能解析任意 Session。第二是 CSRF:Cookie 会在跨站请求中自动发送,因此应按照 OWASP CSRF 备忘单的要求,将 SameSite 与使用常量时间比较的 CSRF Token 结合使用。HttpOnly 标志封堵了 XSS 读取路径;SameSite 加 CSRF Token 封堵了 CSRF 路径。

JWT 与令牌:以吊销成本换取无状态扩展

只有在存在明确的无状态扩展需求时,才应考虑使用原生 JWT——例如需要服务多种客户端类型的 API、在微服务之间传递身份信息,或者 Cookie 使用不便的跨域访问场景——而不应将其作为默认选择。其收益是真实存在的:签名令牌可以在本地通过公钥进行验证,从而跳过逐请求的存储层查询,任意节点均可对任意请求进行认证。

JWT 的难点不在于签发,而在于吊销。由于令牌是自包含的,无论服务端如何判断,它在过期之前始终有效。因此,要实现即时登出,要么需要配合短期令牌生命周期与刷新令牌轮换机制,要么需要维护一个在每次请求时都要检查的服务端黑名单——而后者悄然重新引入了 JWT 本来试图消除的服务端状态。标准模式是将短期访问令牌与生命周期较长、可轮换的刷新令牌配合使用;OAuth 2.0 安全最佳当前实践(RFC 9700,2025 年 1 月)是刷新令牌轮换和短期访问令牌生命周期的权威规范依据——比博客文章中流传的那些整数 TTL 值更具参考价值。

令牌的存储位置决定了安全漏洞的波及范围。将 JWT 存储在 localStorage 中现已成为反模式:页面上任何 XSS 漏洞都可以读取并窃取令牌,因此应将令牌保存在 JavaScript 层无法访问的 HttpOnly Cookie 中。针对浏览器应用,IETF 的《OAuth for Browser-Based Apps》最佳当前实践草案推荐使用带 PKCE 的授权码流程,并警告称恶意 JavaScript 能做的远不止读取一个令牌。

# FastAPI: verify an access token with PyJWT (not python-jose)
import jwt  # PyJWT
from datetime import datetime, timezone
from fastapi import HTTPException

def verify_access_token(token: str, public_key: str) -> dict:
    try:
        return jwt.decode(
            token, public_key,
            algorithms=["RS256"],          # never accept "none"; pin the alg
            options={"require": ["exp", "iat", "sub"]},
        )
    except jwt.ExpiredSignatureError:
        raise HTTPException(401, "token expired")  # client should refresh
    except jwt.InvalidTokenError:
        raise HTTPException(401, "invalid token")

# When you mint tokens, use timezone-aware UTC:
# exp = datetime.now(timezone.utc) + timedelta(minutes=15)

以下两点版本说明有助于保持代码的时效性。请使用 PyJWT——FastAPI 官方安全教程已将其(import jwt)与 pwdlib 一同迁入,而 python-jose 目前仅处于最低限度的维护状态。另外,请使用 datetime.now(timezone.utc) 来生成过期时间:datetime.utcnow() 已在 Python 3.12 中被标记为废弃,根据 Python 待移除功能文档,该函数仍处于废弃状态,尚无明确的移除版本计划——它目前仍可使用,但时区感知的调用方式才是官方推荐的替代方案。

OAuth 2.0/2.1 与 OIDC:按需使用的委托身份验证

当你需要用户通过现有身份提供商登录——“使用 Google 登录”、GitHub、Microsoft、Okta——或者需要企业级 SSO 时,应使用 OAuth 2.0 配合 OpenID Connect。这类似于 VIP 宾客名单模型:你不亲自验证用户身份,而是信任一个已经了解该用户的签发方。OAuth 2.0 处理委托授权(用于 API 的访问令牌),OpenID Connect 则在其之上叠加了身份令牌,让你能够获知用户是谁

只有当委托授权或 SSO 是真实需求时,引入 OAuth 才值得承担其复杂度——因为该流程涉及诸多实际组件:授权码、PKCE、Scope、重定向 URI 以及 JWKS 密钥轮换。对于仅需第一方登录、没有社交登录需求的 MVP 而言,OAuth 是过度设计;你将承担重定向机制的全部复杂度,却用不到它所提供的委托能力。

如果确实需要采用 OAuth,请以现代规范为目标。OAuth 2.1 目前仍是 IETF 互联网草案(draft-15,发布于 2026 年 3 月 2 日),尚未成为正式 RFC,但其方向已经明确。相比 OAuth 2.0 的主要变化包括:对所有使用授权码流程的客户端强制要求 PKCE,重定向 URI 必须使用精确字符串匹配进行比较,以及移除了隐式授权(Implicit Grant)和资源所有者密码凭证授权(Resource Owner Password Credentials Grant)。IETF 本身指出,互联网草案的有效期最长为六个月,除作为”进行中的工作”引用外,不宜以其他方式引用——因此应将 2.1 视为目标整合规范(主流身份提供商已在实践中强制执行),而非已发布的正式标准。实质内容才是关键:对所有客户端(包括机密的服务端客户端)使用授权码 + PKCE,并弃用通配符重定向 URI。

Passkey 与 WebAuthn:防钓鱼的发展方向

将 Passkey 作为防钓鱼因子加以集成,而非将其视为一种信仰。Passkey 基于 WebAuthn/FIDO2 构建,本质上是一对公私钥,私钥存储在设备的安全硬件中,在本地完成生物特征或 PIN 验证后对服务端挑战进行签名。由于凭证与源(Origin)绑定且从不传输,它能够以密码和短信验证码无法企及的方式抵御钓鱼攻击、重放攻击和凭证填充攻击。

政策层面已发生决定性转变。英国国家网络安全中心(NCSC)于 2026 年 4 月宣布,Passkey 现在应成为消费者在所有数字服务中的首选登录方式,并将开始在支持 Passkey 的服务中推荐使用 Passkey,在不支持的服务中推荐使用两步验证——这一转变体现在持续更新的指导方针中,而非某次突然的政策变化Passkey 的支持已基本普及:Windows、ChromeOS、macOS、Android、iOS 及众多 Linux 发行版等主流操作系统,以及 Chrome、Firefox、Edge 和 Safari 等浏览器均已支持。

Passkey 的技术支持问题已经解决;Passkey 的注册体验问题尚未解决——而这才是真正的选型约束。根据 Corbado 2026 年 Passkey 基准报告(这是厂商基准数据,并非中立的人口统计数据),Web 端首次注册成功率在 iOS 上约为 49%–83%,但在 Windows 上仅为 25%–39%。应将注册用户体验和账户恢复作为一等设计问题对待:以可选升级的方式提供 Passkey,并提供清晰的回退方案;在上线前就设计好恢复流程,因为设备丢失且没有恢复路径意味着用户将被永久锁定。

身份验证即服务:当认证不是你的核心产品时的务实默认选择

如果身份验证不是你的核心产品,2026 年最稳妥的默认选择是托管身份验证服务商——Auth0ClerkSupabase AuthWorkOSStytch——因为你可以直接继承 Session 管理、社交登录、MFA 和 Passkey 能力,而无需自行承担安全漏洞的风险面或维护负担。这些服务商提供开箱即用的 SDK,处理完整的生命周期,包括你本来需要自行构建和调试的 OAuth 重定向机制和 WebAuthn 注册流程。

这个决策在经济层面一目了然。自行实现给你带来完全的控制权和零 MAU 成本;托管服务商则提供经过强化的实现、更快的登录上线速度,以及由他人承担安全关键路径的值班责任。对于大多数将登录视为基础能力而非差异化竞争优势的绿地项目而言,托管方案是理性的起点——如果规模或成本有要求,之后随时可以迁移。

身份验证方法决策矩阵

策略应与应用架构形态相匹配。找到对应你的架构的行,从那里出发,并在所有方案中统一将 Passkey 作为防钓鱼因子加以集成。

应用架构形态起点方案按需叠加
服务端渲染的单体应用服务端 Session(HttpOnly Cookie)OIDC 用于社交登录;Passkey 可选升级
SPA / 移动端 / API 后端短期访问令牌 + 刷新令牌轮换,存于 HttpOnly CookieOIDC 用于第三方身份
需要社交登录或企业级 SSOOAuth 2.0 + OIDC(以 2.1 规范为目标)Session 或 Token 用于自有服务面
认证不是核心产品托管服务商(Auth0/Clerk/Supabase/WorkOS/Stytch)— 已内置其余所有能力
高安全性 / 面向消费者以上任意基础方案 + Passkey提前设计好账户恢复流程

常见的生产环境架构是混合模式:服务端渲染 Web 应用使用 Session,SPA 和移动端消费的 API 使用 JWT,社交登录使用 OIDC,Passkey 作为可选升级。这一切背后的行业趋势是:减少密码的发送次数,缩小发送范围,并赋予更细粒度的权限控制和可审计性——这正是为什么有作用域的令牌取代了基于密码的 Git 认证,也是为什么 Passkey 正在彻底取代密码。

Session 回放揭示的认证用户体验失效模式

Session 回放是一种重建真实客户端流程的技术,对认证流程进行回放可以发现一系列后端日志所遗漏的典型失效模式——因为在纸面上选择了正确的策略,并不能保证用户实际上能够成功登录(以下是该技术的能力范畴,而非已测量的事故发生率):

  • 静默令牌过期导致的登出循环。 当刷新令牌在 Session 中途过期时,SPA 可能会反复将用户重定向到登录页。回放可在时间轴上清晰呈现这一重定向循环,而错误日志会将其遗漏,因为每个单独的请求都返回了技术上合法的 401 响应。
  • Passkey 注册中途放弃。 回放可以精确定位用户在 WebAuthn 注册流程中的退出位置——操作系统弹窗出现后用户取消——这正是 Windows 端低注册率数据所预示的失效场景。
  • OAuth 重定向死胡同。 授权同意后重定向到空白页或错误页(重定向 URI 不匹配、state 参数丢失)在后端日志中是不可见的,因为整个往返过程发生在你的服务器之外;回放则能以用户视角捕捉到这一死胡同。
  • 浏览器特定的 Cookie 失效问题。 本地无法复现的 SameSite 和 Secure Cookie 问题,会在回放将失败 Session 与特定浏览器及版本关联起来时浮现。

关键在于:身份验证不仅仅是协议选择,更是一个用户体验界面——而你所选择的策略,决定了你将调试哪类失效问题。

选择与你应用架构形态相匹配的基础方案,强化凭证存储(使用 HttpOnly Cookie,绝不使用 localStorage),并将 Passkey 作为可选的防钓鱼因子加以集成。对于大多数新应用而言,诚实的第一步是选择托管服务商或以 Session 优先的单体架构;只有在真实的无状态扩展需求能够证明吊销成本合理时,才考虑使用原生 JWT。登录功能上线后,持续观察真实流程——一个合理策略与一个真正可用的策略之间的差距,恰恰体现在那些只有在生产环境中才会显现的用户体验细节上。

常见问题

新 Web 应用应该默认使用 JWT 吗?

不应该。只有在存在明确的无状态扩展需求时,才应使用原生 JWT,例如需要服务多种客户端类型的 API,或在微服务之间传递身份信息的场景。对于服务端渲染的单体应用,服务端 Session 更为简单,且可以实现即时吊销。JWT 是自包含的,因此在过期前吊销令牌需要配合短期令牌生命周期与刷新令牌轮换机制,或维护服务端黑名单——而后者重新引入了 JWT 本来试图消除的服务端状态。

Session Token 和 JWT 是同一回事吗?

不是。Session Token 是一个不透明 ID,约 32 字节,指向存储在服务端的认证状态,因此每次请求都需要进行一次查询。JWT 则将声明内联携带,通过本地签名验证,无需查询,但每次请求往往增加 800 字节以上的开销。两者代表了无状态与有状态的核心权衡:Session 将状态保存在服务端,而 JWT 将其移入客户端的签名凭证中。

如果我对输入进行了净化处理,可以将 JWT 存储在 localStorage 中吗?

不可以。无论输入净化做得多彻底,将 JWT 存储在 localStorage 中都是反模式——因为页面上任何位置的跨站脚本漏洞都可以读取 localStorage 并窃取令牌。应将凭证保存在 JavaScript 无法访问的 HttpOnly Cookie 中,并配合 SameSite 和 CSRF Token 使用。对于浏览器应用,IETF 的《OAuth for Browser-Based Apps》最佳当前实践草案推荐使用带 PKCE 的授权码流程,而非客户端可读的令牌存储方式。

对于只有第一方登录的 MVP,OAuth 是否过度设计?

是的。如果你的 MVP 只需要第一方登录,没有社交登录或企业级 SSO 需求,OAuth 2.0 会引入你根本用不到的机制。该流程包含授权码、PKCE、Scope、重定向 URI 以及 JWKS 密钥轮换,这些都是为支持委托身份而存在的。只有当"使用 Google 登录"、GitHub 或企业级 SSO 是真实需求时,才应采用 OAuth 配合 OpenID Connect;否则,请从 Session 或托管服务商起步。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.