Web 应用的会话管理:Cookie、JWT 与刷新令牌的对比
比较 Web 应用的会话管理:基于 cookie 的会话、JWT 与刷新令牌,通过具体的威胁模型和现实的登出需求来说明。

会话管理实际上在做什么
会话就是在用户登录后,你的应用用来回答一个问题的方式:"你现在是谁?" 一旦这个答案可靠,应用就能决定用户可以看到什么、能修改什么,以及哪些操作必须被阻止。
“保持登录”也是一个安全选择。你在决定用户身份应该保持多长时间有效,身份凭证存放在哪里,以及如果凭证被复制会发生什么。
大多数 Web 应用的设置依赖三种构件:
- 基于 Cookie 的服务器会话:浏览器存储一个 cookie,服务器在每次请求时查找会话记录。
- JWT 访问令牌:客户端发送一个签名令牌,服务器无需数据库查找即可验证。
- 刷新令牌:一种更长期的凭证,用于获取新的短期访问令牌。
这些并不是相互竞争的“风格”,而是处理相同取舍的不同方式:速度 vs 控制、简单性 vs 灵活性,以及“我们能立刻使其失效吗?” vs “它会自行过期吗?”
评估任何设计的一个有用方式是:如果攻击者窃取了你应用用作凭证的东西(cookie 或令牌),他们能做什么,持续多久?当你需要强大的服务器端控制(比如强制登出或即时锁定)时,cookie 会话通常占上风。JWT 适合跨服务的无状态校验,但当你需要立即撤销时会很麻烦。
没有一种方案在所有场景下都是最优的。正确的方法取决于你的威胁模型、对登出要求的严格程度,以及你的团队能实际维护多少复杂性。
会改变最佳答案的威胁模型
良好的会话设计与其说取决于“最佳”的令牌类型,不如说取决于你需要抵御哪些攻击。
如果攻击者从浏览器存储中窃取数据(如 localStorage),JWT 访问令牌很容易被抓取,因为页面的 JavaScript 可以读取它们。被窃取的 cookie 情形有所不同:如果设置为 HttpOnly,普通页面代码无法读取它,因此简单的“令牌抓取”攻击会更难。但如果攻击者拿到了设备(丢失的笔记本、恶意软件、共享电脑),cookie 仍然可能从浏览器配置中被复制。
XSS(攻击者在你的页面上运行代码)改变了一切。在有 XSS 的情况下,攻击者可能不需要窃取任何东西。他们可以利用受害者已登录的会话来执行操作。HttpOnly cookie 有助于防止读取会话秘密,但它们并不能阻止攻击者从页面发起请求。
CSRF(来自另一个站点触发的不良操作)主要威胁基于 cookie 的会话,因为浏览器会自动附带 cookie。如果你依赖 cookie,就需要明确的 CSRF 防御:合理设置 SameSite、使用防 CSRF 令牌,并谨慎处理会改变状态的请求。通过 Authorization 头发送的 JWT 对经典 CSRF 暴露较少,但如果把它们存放在 JavaScript 可读的地方,它们仍会被 XSS 暴露。
重放攻击(重用被窃凭证)是服务器端会话的强项:你可以立即使会话 ID 失效。短期有效的 JWT 缩短了重放时间,但在令牌有效期间并不能阻止重放。
共享设备和丢失手机把“登出”变成真实的威胁模型。决策通常归结为:用户能否强制登出其他设备、必须多快生效、如果刷新令牌被盗会怎样、是否允许“记住我”会话?许多团队对员工访问设置比客户访问更严格的标准,这会改变超时和撤销预期。
Cookie 会话:它们如何工作以及保护了什么
基于 Cookie 的会话是经典做法。登录后,服务器创建会话记录(通常是一个 ID 加上一些字段,如用户 ID、创建时间和过期时间)。浏览器只在 cookie 中存储会话 ID。每次请求时,浏览器发送该 cookie,服务器查找会话并决定用户身份。
最大的安全优势是控制权。会话在服务器端每次都被验证。如果你需要把某人踢出,你删除或禁用服务器端的会话记录,它就会立即停止生效,即使用户仍然持有 cookie。
很多保护来自 cookie 设置:
- HttpOnly:阻止 JavaScript 读取 cookie。
- Secure:仅通过 HTTPS 发送 cookie。
- SameSite:限制浏览器在跨站请求时何时发送 cookie。
你把会话状态存在哪里会影响扩展性。在应用内存中保存会话简单,但在运行多台服务器或频繁重启时会出问题。数据库在持久性上表现良好;当你需要快速查找和大量活动会话时,Redis 很常见。关键点是:服务器必须能在每次请求时找到并验证会话。
当你需要严格的登出行为时(比如员工控制台或管理员必须在角色变更后强制登出的客户门户),Cookie 会话是很合适的选择。如果一名员工离职,禁用其服务器端会话即可立即结束访问,无需等待令牌过期。
JWT 访问令牌:优点与棘手之处
JWT(JSON Web Token)是一个签名字符串,包含一些关于用户的声明(比如用户 ID、角色、租户)和一个过期时间。你的 API 本地验证签名和过期,然后授权请求,无需调用数据库。
这就是 JWT 在以 API 为先的产品、移动应用和多个服务需要验证同一身份时受欢迎的原因。如果你有多台后端实例,每一台都可以验证同一个令牌并得到相同答案。
优点
JWT 访问令牌校验速度快,且便于随 API 调用传递。如果前端调用许多端点,短期的访问令牌可以使流程简单:验证签名,读取用户 ID,继续处理。
示例:客户门户分别调用“列出发票”和“更新资料”的不同服务。JWT 可以携带客户 ID 和像 customer 这样的角色,使每个服务在不查会话的情况下进行授权。
棘手之处
最大的权衡是撤销问题。如果令牌在一小时内有效,通常在这一个小时内它在所有地方都有效,即使用户点击了“登出”或管理员禁用了账户,除非你额外添加服务器端检查。
JWT 也会以常见方式泄露。常见的失败点包括 localStorage(XSS 可读取)、浏览器内存(恶意扩展)、日志和错误报告、捕获头信息的代理和分析工具,以及支持聊天或截图中被复制的令牌。
因此,JWT 访问令牌最适合作为短期访问,而不是“永久登录”。保持它们简短(不要在里边放敏感个人数据)、缩短过期时间,并假设被窃的令牌在过期前可被使用。
刷新令牌:让 JWT 方案可行
JWT 访问令牌应该是短期的。这对安全有利,但带来了实际问题:用户不应该每隔几分钟就要重新登录。刷新令牌解决了这个问题:当访问令牌过期时,应用可以静默地获取新的访问令牌。
刷新令牌存放的位置比访问令牌更重要。在基于浏览器的 Web 应用中,最安全的默认做法是将刷新令牌放在 HttpOnly、Secure 的 cookie 中,这样 JavaScript 无法读取。将其放在 localStorage 实现起来更容易,但如果出现 XSS 漏洞也更容易被窃取。如果你的威胁模型包含 XSS,避免将长期凭证放在 JavaScript 可读的存储中。
轮换(rotation)使刷新令牌在真实系统中可行。不是一直使用同一个刷新令牌数周,而是在每次使用时交换:客户端提交刷新令牌 A,服务器发放新的访问令牌和刷新令牌 B,同时使刷新令牌 A 失效。
一个简单的轮换设置通常遵循几个规则:
- 将访问令牌设为短期(几分钟,而不是数小时)。
- 在服务器端存储刷新令牌及其状态和最后一次使用时间。
- 每次刷新时轮换并使之前的令牌失效。
- 尽可能把刷新令牌与设备或浏览器绑定。
- 记录刷新事件以便调查滥用。
重复使用检测是关键警报。如果刷新令牌 A 已被交换过,但你之后又再次看到它,假设它被复制。常见响应是撤销整个会话(通常是该用户的所有会话)并要求重新登录,因为你无法知道哪个副本是真实的。
对于登出,你需要服务器可以强制执行的东西。那通常意味着一个会话表(或撤销列表)来标记刷新令牌为已撤销。访问令牌在过期前可能仍然可用,但你可以通过把访问令牌设为短期来将这个窗口缩小。
登出要求与实际可执行的范围
“登出”听起来简单,直到你定义它。通常有两类不同的需求:“登出此设备”(单个浏览器或手机)和“登出所有设备”(账户所有活跃会话)。
还有时间性问题。“立即登出”意味着应用现在就停止接受该凭证。“到期后登出”意味着应用在当前会话或令牌自然过期时停止接受它。
在基于 Cookie 的会话中,立即登出很直接,因为服务器掌控会话。你在客户端删除 cookie 并使服务器端会话记录失效。如果有人之前复制了 cookie 值,服务器端的拒绝才是真正强制登出的方式。
在纯 JWT 认证(无服务器查验的无状态访问令牌)下,你无法真正保证立即登出。被窃的 JWT 在过期前仍然有效,因为服务器无处可查“此令牌是否已被撤销?”你可以添加一个拒绝列表,但那样你就开始保存状态并进行检查,这会失去最初的简洁性。
一种实用模式是将访问令牌视为短期,并通过刷新令牌来强制登出。访问令牌可以短暂存在几分钟,刷新令牌才是维持会话的关键。如果笔记本被盗,撤销刷新令牌家族会迅速切断未来访问。
你可以现实地向用户承诺的内容:
- 登出此设备:撤销该会话或刷新令牌,并删除本地 cookie 或存储。
- 登出所有设备:撤销该账户的所有会话或所有刷新令牌家族。
- “立即”生效:服务器会话可以保证立即生效;访问令牌则在过期前为尽力而为。
- 强制登出事件:密码变更、账户被禁用、角色降级等情形。
对于密码更改和账户禁用,不要指望“用户会登出”。存储一个全局的会话版本(或一个“令牌有效起始时间”)。在每次刷新(有时也在每次请求)时进行比较。如果它改变了,则拒绝并要求重新登录。
逐步指南:为你的应用选择会话方案
如果你希望会话设计保持简单,先决定规则再选择实现方式。大多数问题源于团队因为流行而选择 JWT 或 cookie,而不是因为它们匹配风险和登出要求。
先列出用户在哪些地方登录。浏览器应用与原生移动应用、内部管理工具或合作方集成的行为各不相同。每种情况都会改变什么可以安全存储、如何续期登录以及“登出”应意味着什么。
对大多数团队实用的一个顺序:
- 列出你的客户端:Web、iOS/Android、内部工具、第三方访问。
- 选择一个默认威胁模型:XSS、CSRF、设备被盗。
- 决定登出必须保证的内容:仅此设备、所有设备、管理员强制登出。
- 选择基线模式:基于 Cookie 的会话(服务器记忆)或访问令牌 + 刷新令牌。
- 设置超时与响应规则:空闲 vs 绝对过期,以及在检测到可疑重复使用时的处理。
然后把系统承诺写下来。例如:“Web 会话空闲 30 分钟或绝对 7 天到期。管理员可以在 60 秒内强制登出。丢失手机可以远程禁用。” 这些句子比你使用的库更重要。
最后,添加与模式相匹配的监控。对于令牌方案,刷新令牌重复使用是强信号。将其视为可能被盗,撤销令牌家族并提醒用户。
导致帐户接管的常见错误
大多数帐户接管并非“高明的攻击”,而是可预见的会话错误带来的简单胜利。良好的会话处理主要是不要给攻击者窃取或重放凭证的轻松方式。
一个常见陷阱是把访问令牌放在 localStorage 并指望永远不会有 XSS。如果页面上运行了任何脚本(不安全的依赖、被注入的小部件、存储型评论),它可以读取 localStorage 并把令牌发走。带有 HttpOnly 标志的 cookie 可以降低这种风险,因为 JavaScript 无法读取它们。
另一个陷阱是为了避免刷新令牌而让 JWT 长期有效。一个 7 天的访问令牌意味着如果泄露就有 7 天的重用期。短期访问令牌加上管理良好的刷新令牌更难被滥用,尤其是在你能切断刷新时。
Cookie 也有自己的风险:忘记 CSRF 防护。如果你的应用使用 cookie 认证但接受更改状态的请求而没有 CSRF 防护,恶意站点可以诱使已登录的浏览器发送有效请求。
事故复盘中常见的其他错误包括:
- 刷新令牌从不轮换,或者轮换了但你没有检测重复使用。
- 支持多种登录方式(会话 cookie 和 Bearer 令牌),但服务器的“哪个优先”规则不清晰。
- 令牌出现在日志中(浏览器控制台、分析事件、服务器请求日志),被复制和保留。
一个具体例子:支持人员把“调试日志”粘贴到工单里,日志包含 Authorization 头。任何有工单访问权限的人都可以重放该令牌并以该代理身份操作。把令牌当成密码:不要打印它们、不要存储它们、并保持短期有效。
上线前的快速检查
大多数会话漏洞不是关于复杂的密码学,而是关于一个缺失的标志、一个存在时间过长的令牌或一个本应要求重新认证的端点。
发布前,做一次短而集中的检查:关注攻击者用被窃 cookie 或令牌能做什么。这是提升安全而无需重写整个认证系统的最快方法之一。
发布前检查清单
在预生产环境走一遍,然后在生产中再检查一次:
- 将访问令牌设为短期(分钟级),并确认 API 在过期后确实拒绝它们。
- 把刷新令牌当密码:尽可能存放在 JavaScript 无法读取的地方,仅发送到刷新端点,并在每次使用后轮换。
- 如果用 cookie 做认证,验证标志:HttpOnly 开启、Secure 开启、SameSite 根据需要设置。还要确认 cookie 的作用域(域和路径)不要过宽。
- 如果 cookie 用于认证请求,添加 CSRF 防护,并确认更改状态的端点在没有 CSRF 信号时失败。
- 使撤销真实可行:密码重置或账户禁用后,现有会话应快速失效(服务器端删除会话、刷新令牌失效或“会话版本”校验)。
之后测试你的登出承诺。“登出”常常意味着“删除本地会话”,但用户期望更多。
一个实用测试:在笔记本和手机上登录,然后更改密码。笔记本应在下一次请求时被迫退出,而不是几小时后。如果你提供“全部登出”和设备列表,确认每个设备映射到可撤销的独立会话或刷新令牌记录。
示例:带员工账户和强制登出的客户门户
想象一个小型企业,有一个 Web 客户门户(客户查看发票、打开工单)和一个供外勤员工使用的移动应用(工作、笔记、照片)。员工有时在没信号的地下室工作,所以应用必须在短时间内离线工作。管理员也想要一个大红按钮:当平板丢失或承包商离职时,可以强制登出。
现在加上三个常见威胁:货车里的共享平板(有人忘记登出)、钓鱼(员工把凭证输入假页面)、以及门户偶发的 XSS 漏洞(脚本在浏览器内运行并试图窃取一切)。
这里一个实用的设置是短期访问令牌加上轮换的刷新令牌,并配合服务器端撤销。这能提供快速的 API 调用和离线容错,同时允许管理员切断会话。
可能的配置如下:
- 访问令牌有效期:5 到 15 分钟。
- 刷新令牌轮换:每次刷新返回新的刷新令牌,旧令牌被使失效。
- 安全存储刷新令牌:Web 上将刷新令牌放在 HttpOnly、Secure 的 cookie 中;移动端放在操作系统的安全存储中。
- 在服务器端跟踪刷新令牌:保存令牌记录(用户、设备、签发时间、最后使用时间、撤销标志)。如果已轮换的令牌被重用,则视为被盗并撤销整个链条。
强制登出变得可执行:管理员撤销该设备的刷新令牌记录(或该用户的所有设备)。被盗设备可以继续使用当前访问令牌直到其过期,但无法获得新的访问令牌。这样完全切断访问的最长时间就是你的访问令牌有效期。
对于丢失设备,用明白的语言定义规则:“在 10 分钟内,应用将停止同步并要求重新登录。” 离线工作可以保留在设备上,但下一次在线同步应在用户重新登录前失败。
接下来的步骤:实现、测试并保持可维护性
把“登出”用产品语言写清楚。例如:“登出会移除该设备的访问”、“全部登出将在 1 分钟内踢出所有设备”或“更改密码会登出其他会话”。这些承诺决定了你是否需要服务器端会话状态、撤销列表或短期令牌。
把承诺转化为一个小的测试计划。令牌和会话错误在正常流程中往往看不出来,但在真实场景(睡眠模式、不稳定网络、多设备)中会暴露问题。
一个实用的测试清单
运行覆盖混乱场景的测试:
- 过期:当访问令牌或会话到期时即使浏览器仍然打开也要停止访问。
- 撤销:在“全部登出”后,旧凭证应在下一次请求时失效。
- 轮换:刷新令牌轮换后应发放新刷新令牌并使旧的失效。
- 重用检测:重放旧刷新令牌应触发锁定响应。
- 多设备:对“仅当前设备”与“所有设备”的规则进行强制检验,并确保 UI 与后端一致。
测试后,与团队做一次简单的攻击演练。选三个故事并端到端演练:一个能读取令牌的 XSS 漏洞、针对 cookie 会话的 CSRF 企图、以及带有活动会话的被盗手机。你要检查设计是否与承诺一致。
如果你需要快速推进,减少自定义粘合代码。AppMaster (appmaster.io) 是一个选项,当你想要生成生产就绪的后端与 Web/原生移动应用时,它可以帮助你在客户端间保持过期、轮换与强制登出等规则一致。
发布后安排一次复审。利用真实的支持工单和事故来调整超时、会话限制和“全部登出”行为,然后再次运行相同的清单以防修复悄然回归。


