PAYATHON 2026

Next.js App Router 中如何安全存储 JWT 并处理刷新?

数据阿明

建议采用什么方案?

这个应用使用自定义后端,建议由 Next.js 服务端访问后端,浏览器通过同源请求与 Next.js 交互。会话凭据保存在浏览器的 HttpOnly Cookie 中,避免把长期有效的 JWT 或 refresh token 放进 localStorage。

有两种可行的存储方式:

  • 实现较简单:JWT 保存在 HttpOnly Cookie 中,Next.js 服务端读取后,通过 Authorization: Bearer ... 调用后端。
  • 需要集中管理刷新、注销和撤销:浏览器只保存随机会话 ID,JWT 和 refresh token 保存在服务端会话存储中。这需要增加数据库或其他共享存储。

HttpOnly Cookie 可以与 Server Actions 配合使用:前者决定凭据怎样存储,后者提供登录、注销等服务端操作入口。Next.js cookies 文档说明了不同执行环境的读写能力。

原问题没有说明后端是否提供 refresh token 或刷新接口,因此不能假定可以静默续期。如果后端只返回一个会过期的 JWT,又没有其他续期机制,令牌过期后就需要重新认证。

三种方案的区别是什么?

方案在当前场景中的作用主要限制
HttpOnly Cookie浏览器保存会话凭据,服务端读取Cookie 会随符合条件的请求自动发送,需要防范 CSRF
localStorage客户端读取 JWT 并构造请求头同源 JavaScript 可读取,XSS 可导致令牌泄露;服务端也不能直接读取它
NextAuth.js / Auth.js组织认证流程与会话管理仍需接入自定义后端,并实现符合后端协议的刷新逻辑

原问题担心 CMS、分析脚本等带来供应链攻击,这个顾虑成立:只要恶意脚本获得页面执行权限,就能读取同源 localStorage 中的令牌。HttpOnly 能阻止脚本直接读取 Cookie,但恶意脚本仍可能借助当前页面发起已认证请求,因此还需要防范 XSS。OWASP 浏览器存储安全指南

各层应该负责什么?

Middleware:提前检查和跳转

Middleware 可以执行低成本检查,例如判断会话 Cookie 是否存在,或在具备验证条件时检查签名与有效期,再决定是否跳转到登录页。

不要在每次路由访问时都远程查询会话或刷新令牌。路由预取也可能触发这一层,额外的网络请求会增加等待时间,还可能放大并发刷新问题。

Cookie 存在不代表用户已经通过认证,单纯解码 JWT 也不代表签名有效。数据访问和权限判断应放在数据访问层、Server Actions、Route Handlers 及后端接口中。Next.js 的认证指南也将提前检查与敏感数据访问时的授权检查分开处理。

Next.js 16 将 middleware 文件约定重命名为 proxy。原问题没有给出版本,应按项目实际版本选择 middleware.ts 或 proxy.ts。Next.js 16 升级指南

Server Actions 与 Route Handlers:建立和更新会话

登录时,由 Server Action 或 Route Handler 调用后端登录接口,再把凭据写入 Cookie。注销时清除 Cookie;如果后端支持撤销或服务端会话,还应同步结束对应会话。

在 App Router 中,HTTP 接口通常使用 Route Handlers,即 app/api/.../route.ts。

Server Components:读取会话并获取数据

Server Components 可以读取请求中的 Cookie,并通过服务端数据访问函数直接调用后端。

它们在渲染期间不能直接设置或删除 Cookie。通过服务端 fetch 调用自己的刷新接口时,也不能假定该接口返回的 Set-Cookie 会自动传给浏览器,因为这是两次不同的 HTTP 响应。Next.js cookies 文档

Client Components:调用同源入口

Client Components 通过 Server Actions 或同源 Route Handlers 完成交互,通常不需要拿到 JWT。浏览器向同源接口发送请求时会携带符合条件的 Cookie,Next.js 再向后端添加 Authorization 请求头。

传给客户端的会话信息应只包含 UI 所需字段,例如昵称和展示权限。不要通过 props、接口响应或页面序列化数据把 JWT 再暴露给客户端。Next.js BFF 指南

下面是直接把 access token 存入 Cookie 的最小结构。假设后端使用邮箱和密码登录,返回 { accessToken, expiresIn },其中 expiresIn 是剩余有效秒数。接口路径和字段均为示例,需要替换为真实协议。

BACKEND_URL 应由服务端配置,指向可信的 HTTPS 后端,不能接受浏览器任意指定。

// app/actions.ts
'use server'

import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'

export async function login(formData: FormData) {
  const email = formData.get('email')
  const password = formData.get('password')

  if (
    typeof email !== 'string' ||
    typeof password !== 'string' ||
    !email || !password
  ) {
    return { error: '请输入邮箱和密码' }
  }

  const response = await fetch(
    new URL('/auth/login', process.env.BACKEND_URL!),
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password }),
      cache: 'no-store',
    }
  )

  if (!response.ok) {
    return { error: '登录失败' }
  }

  const data = await response.json()

  if (
    typeof data.accessToken !== 'string' ||
    !data.accessToken ||
    !Number.isInteger(data.expiresIn) ||
    data.expiresIn <= 0
  ) {
    throw new Error('后端认证响应格式不符合约定')
  }

  const cookieStore = await cookies()
  cookieStore.set('__Host-access_token', data.accessToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
    maxAge: data.expiresIn,
  })

  redirect('/dashboard')
}

这里的字段检查只验证响应格式,身份认证由后端登录接口执行。实际应用还应补充请求超时、登录限流和适当的输入长度限制。

示例使用 HTTPS Cookie:__Host- 前缀要求设置 Secure、Path=/,并且不设置 Domain。本地开发可使用 HTTPS;若需要 HTTP 开发配置,应单独调整 Cookie 名称及属性。Cookie 属性的含义可参照 OWASP 会话管理指南。

服务端数据访问函数可以这样读取令牌:

// lib/backend.ts
import 'server-only'
import { cookies } from 'next/headers'

export async function getProfile() {
  const cookieStore = await cookies()
  const token = cookieStore.get('__Host-access_token')?.value

  if (!token) return null

  const response = await fetch(
    new URL('/me', process.env.BACKEND_URL!),
    {
      headers: { Authorization: `Bearer ${token}` },
      cache: 'no-store',
    }
  )

  if (response.status === 401) return null
  if (!response.ok) throw new Error('获取用户信息失败')

  return response.json()
}

这个例子没有实现刷新。后端 /me 必须验证 JWT 并执行授权;如果 Next.js 自己依据 JWT 中的角色或用户 ID 授权,也必须验证签名及约定的 claims,不能只解码后信任内容。

Server Components 可以直接调用这个函数。如果 Client Components 需要资料,可以增加同源 /api/me Route Handler,调用该函数并只返回允许公开给当前用户的字段。用户专属响应和包含 Set-Cookie 的响应应禁止共享缓存。

怎样刷新才能减少延迟和竞态?

先确认后端的刷新协议,包括是否提供 refresh token、是否轮换、旧令牌何时失效,以及如何撤销会话。

如果后端支持刷新,可以按以下流程组织:

  1. 平常直接使用仍有效的 access token,不为每个请求额外调用刷新接口。
  2. 在接近过期,或后端明确返回令牌过期的认证错误时,进入统一刷新逻辑。
  3. 同一会话的并发请求共享一次刷新结果,避免各自使用同一个旧 refresh token 发起轮换。
  4. 刷新成功后保存新凭据,再按条件重试原请求;刷新凭据无效或已撤销时结束会话,临时网络故障则单独处理。

刷新令牌放在浏览器 Cookie 时,建议由浏览器请求一个受保护的 POST Route Handler,或调用 Server Action,让响应真正把新 Cookie 写回浏览器。随后重新请求数据或调用 router.refresh(),让新的 Server Component 请求携带更新后的 Cookie。

首次 SSR 就遇到过期 JWT 时,需要设计恢复流程,例如显示续期入口,由浏览器完成上述 POST 后重新请求页面。如果希望服务端渲染过程中直接完成续期,可以采用稳定的会话 ID,把 token 放在服务端共享存储中。刷新时更新服务端记录,无须每次轮换都改写浏览器 Cookie。

在 Middleware 中也可以实现刷新,但必须同时处理当前下游请求使用的新凭据,以及返回浏览器的 Cookie。只设置响应 Cookie,不能假定当前请求链中的所有服务端读取都已使用新值。考虑到预取、并发和网络延迟,Middleware 通常不适合作为自定义认证的默认刷新入口。

多实例部署时,进程内 Promise 或锁无法协调其他实例,需要依据后端轮换规则,在共享存储中实现并发协调。对于创建订单等有副作用的请求,也不能无条件自动重试;应先确定认证失败发生在业务执行前,或使用幂等机制。

还需要注意哪些边界?

Cookie 会自动随请求发送,因此使用 HttpOnly 后仍需要 CSRF 防护。SameSite=Lax 可以提供一定保护,但涉及状态变更的 Route Handlers 还应结合 Origin 校验、CSRF token 等措施,且不要用 GET 执行注销或其他状态变更。Server Actions 的框架保护也不能代替业务授权检查。OWASP CSRF 防护指南

使用自定义后端也可以采用 Auth.js。其 Credentials provider 可以在 authorize 中调用现有登录服务。后端 JWT 的保存、刷新和错误处理仍需按项目协议接入,也应避免通过客户端 session 暴露后端令牌。Auth.js Credentials 文档

无论是否采用 Auth.js,都应让后端继续验证令牌与资源访问权限,并防止 JWT、refresh token 和密码进入日志。

备注:内容仅供参考。