Token Or Cookie:一个项目bug引起的思考

72 阅读9分钟

大家好,我是你们的老朋友,今天我想从一个故事开始。故事的主人公,叫小明。

小明是个非常优秀的前端开发者,过去几年,他一直在 React Native 和小程序的技术浪潮中乘风破浪。对于他来说,用户认证这套流程早已烂熟于心:登录 -> 服务端下发 Token -> 客户端存入 AsyncStoragewx.setStorageSync -> axios 拦截器统一注入 Authorization: Bearer <Token> 请求头。

这套操作行云流水,优雅,且无状态,堪称现代应用开发的“标准答案”。

最近,小明接手了一个新的 Web 项目,技术栈选用了当下最火的 Next.js App Router。他想都没想,顺手就把这套驾轻就熟的 Bearer Token 模式搬了过来。

一切看起来都很顺利,直到他写完登录和中间件(Middleware),准备验收成果时,诡异的事情发生了……

登录成功后,页面卡在了加载动画上,URL 在 /login/dashboard 之间疯狂闪烁,浏览器网络面板里,一排红色的 307 临时重定向,仿佛在无声地嘲笑着他。

小明懵了:“这套在 React Native 和小程序里百试百灵的方案,怎么到了 Web 就水土不服了?难道是 Next.js 的 Bug?” 🤨

不,这当然不是 Bug。这其实是一个绝佳的机会,让我们能借着小明的“事故现场”,重新审视那两个我们既熟悉又陌生的老朋友:TokenCookie

一、 历史的岔路口:我们为什么需要它们?

要理解它们的区别,我们得先把时钟拨回到“上古时代”。

那时的 Web,基于一个核心原则:HTTP 协议是无状态的(Stateless)

这意味着服务器不会记住你的任何信息。你第一次访问和你第一万次访问,在它眼里没有任何区别。这对于展示静态信息来说没问题,但如果你想实现一个购物车、一个用户登录系统,这就成了天大的麻烦。

于是,为了让服务器能“记住”你是谁,Cookie 诞生了

🍪 Cookie 的诞生 它的设计理念简单粗暴:服务器在响应你时,通过 Set-Cookie 响应头塞一小块数据(比如 sessionId=xyz)给你的浏览器。浏览器很听话,会把这块“曲奇”存起来,并在之后对该网站的每一次请求中,都通过 Cookie 请求头自动带上它。

这样,服务器就能通过检查这块“曲奇”,认出“哦,是老朋友来了”。一个有状态的会话(Session)就此建立。

在很长一段时间里,基于 Session-Cookie 的模式统治了整个 Web 开发。

然而,随着时代的发展,新的挑战出现了:

  • 移动应用的崛起 :iPhone 和 Android 应用需要访问后端的 API,但它们不是浏览器,没有 Cookie 自动管理机制。
  • 单页应用 (SPA) 的流行:前端应用(如 React, Vue)被部署在 a.com,而后端 API 在 b.com,跨域问题让传统的 Cookie 方案变得举步维艰。
  • 分布式/微服务架构:一个请求可能需要经过多个后端服务,如果每个服务都要去共享的 Session 存储(如 Redis)里验证 sessionId,会变得非常复杂和低效。

这时我们需要一种新的凭证,它必须是:

  1. 无状态、自包含的:凭证本身就包含了足够的用户信息和权限信息,服务端无需再次查询数据库或缓存。
  2. 跨平台、跨域友好的:能轻松地在 Web、移动 App、甚至 IoT 设备之间传递。
  3. 易于扩展的:能应对复杂的分布式系统。

于是,Token(特别是 JWT - JSON Web Token)应运而生

JWT 的核心思想是:服务器将用户信息和元数据(如过期时间)进行签名加密后,生成一个字符串(Token)发给客户端。客户端将其存储起来(比如在 localStorage 或 App 的安全存储中),并在每次请求时,通过 Authorization: Bearer <Token> 的方式发送给服务端。服务端收到后,只需用自己的密钥验证签名即可,无需任何外部查询。

这就是为什么小明在 React Native 和小程序的世界里,Bearer Token 模式玩得风生水起。因为在那些环境里,Token 完美地解决了跨平台、无状态的认证需求。

二、 两个世界的碰撞:为什么 Token 在 Next.js 中“失灵”了?

现在,我们回到小明的困境。他遇到的问题,本质上是把 “非浏览器环境” 的最佳实践,错误地应用到了 “现代浏览器 + SSR 框架” 这个新场景中。

让我们在浏览器的语境下,对这两种方案进行一次全方位的硬核 PK。

特性Header Token (存 localStorage)HttpOnly Cookie深度解读
安全性 (防XSS)脆弱 ❌极其强大localStorage 中的数据可以被页面上任何 JS 代码读取。一旦你的网站有 XSS 漏洞,攻击者的 JS 就能轻松窃取 Token。而 HttpOnly Cookie 对 JS 是不可见的,从根本上杜绝了此风险。
传输便利性手动,繁琐 ❌自动,无感这正是小明问题的根源!在 Next.js 这样的 SSR 框架中,很多页面的渲染需要在服务端完成,这意味着服务端在响应页面请求时就需要验证用户身份。无论是用户直接访问 URL 还是通过 router.push 进行客户端导航,都会向服务器发起一个获取页面数据的请求。这个请求并非我们手动控制的 fetch API 调用,因此我们无法为其注入 Authorization 请求头。而 Cookie 则由浏览器在所有同域请求中(包括页面导航)自动携带,完美解决了 SSR 架构下的身份验证传递问题,无缝衔接。
安全性 (防CSRF)默认免疫 ✅曾是弱点,但现在已解决CSRF 攻击依赖于浏览器自动携带 Cookie 的特性。但现代浏览器已支持 SameSite 属性(默认 Lax),能有效防御绝大多数 CSRF 攻击,使得 Cookie 的安全性大大增强。
跨域支持优秀 ✅复杂 ❌这是 Token 的主场。通过 CORS 配置,Header Token 可以轻松实现跨域 API 调用。Cookie 的跨域则需要后端进行复杂的 withCredentialsAccess-Control-Allow-Credentials 配置。

PK 结论:

  • 在纯粹的前后端分离、跨域API通信场景下:Token 因其灵活性和天生的跨域友好性,依然是首选。
  • 在同域的、集成了服务端渲染(如 Next.js)的 Web 应用中HttpOnly Cookie 凭借其无与伦比的安全性和便利性,扳回一城,成为更优的选择。

三、 硬币的另一面:两种方案的固有缺陷

当然,世界上没有完美的技术方案,只有最合适的选择。为了做出最专业的判断,我们必须清醒地认识到它们各自的“阿喀琉斯之踵”。

JWT Token 的“无状态”之痛 🤕

JWT 最大的优点——无状态,也恰恰是它最主要的痛点来源。

  • 痛点一:无法主动失效(泼出去的水) JWT 一旦签发,在它的过期时间(exp)到达之前,它就是有效的。这意味着,如果用户点击“退出登录”,或者管理员希望强制某个用户下线,服务端是无能为力的。Token 就像泼出去的水,收不回来。
    解决方案:你必须引入一个额外的、有状态的“黑名单”机制(例如使用 Redis)。在用户登出或被禁用时,将该 Token 的唯一标识(jti)存入黑名单。之后每次校验 Token 时,除了验证签名,还需查询一次 Redis,看看这个 Token 是否在黑名单上。—— 这无疑增加了系统的复杂性,也违背了 JWT 最初“无状态”的设计哲学

  • 痛点二:令牌泄露 = 身份泄露 这里要澄清一个误区:JWT 的签名机制只能保证其内容(Payload)不被篡改,但无法阻止其本身被窃取。Token 本质上就是一个长字符串,任何人拿到它,在它过期之前,都可以完全冒充你的身份。这再次凸显了将 Token 存储在 localStorage 中是多么危险。

Cookie 的“历史”之恼 🍪

Cookie 作为“老前辈”,虽然在现代浏览器的加持下依然强大,但它也带着一些历史遗留问题。

  • 烦恼一:CSRF 攻击的历史包袱 跨站请求伪造(CSRF)是 Cookie 机制的经典漏洞。攻击者可以引诱你在已登录的 A 网站的浏览器环境下,向 A 网站发送一个恶意的请求。由于浏览器会自动带上 A 网站的 Cookie,这个恶意请求就会被服务器认为是合法的。
    解决方案:幸运的是,现代浏览器提供的 SameSite Cookie 属性(特别是 LaxStrict 值)已经能极大地缓解这个问题。但这需要开发者有清醒的安全意识,正确配置 SameSite 属性。

  • 烦恼二:大小和流量开销 浏览器对单个 Cookie 的大小(约4KB)和每个域名的 Cookie 数量都有限制。此外,由于 Cookie 会被附加到所有同域的 HTTP 请求上,包括对图片、CSS、JS 等静态资源的请求,这会造成不必要的带宽浪费。

四、 架构师的思考:没有银弹,只有场景

小明的故事告诉我们一个深刻的道理:技术选型中没有绝对的“先进”与“落后”,只有“适合”与“不适合”。

  • 不要因为 Cookie 这个词听起来“古典”,就认为它过时了。 HttpOnlySameSite 这两个属性的加持,已经让它成为了现代 Web 应用中一个极其强大的安全工具。
  • 不要因为 Token 听起来“现代”,就把它奉为所有场景的银弹。 它的设计初衷是为了解决无状态和跨端的问题,但在安全性和会话管理方面,它也引入了新的复杂性。

对于正在使用 Next.js、Nuxt.js 或类似框架的开发者,我的建议是:

希望小明的这次“事故”,能让你对 Web 认证的理解更上一层楼。如果你有不同的看法,欢迎在评论区分享出来,我们一起探讨!👍