Next.js中accessToken、refreshToken、csrfToken的安全存储最佳实践咨询
你的当前方案合理性判断
把refreshToken存在HTTP-only、Secure、SameSite=Strict的Cookie里,这绝对是推荐做法——完全阻断了XSS脚本读取refreshToken的可能,从根源上降低了令牌泄露风险。
但accessToken和csrfToken存在Context Provider(内存)的做法,有可取之处但也存在明显局限,不算最优解。
潜在风险与弊端
页面刷新/标签页切换导致令牌丢失
内存中的状态在页面刷新、标签页关闭或新开时会直接清空,用户需要重新触发登录流程(除非你额外做了refreshToken自动刷新逻辑),体验很差。SSR/SSG场景下的状态不匹配
Next.js的服务端渲染(SSR)或静态生成(SSG)过程中,无法访问客户端的Context状态,这会导致需要认证的页面在服务端渲染时拿不到用户信息,出现 hydration 不匹配的错误,或者渲染出未登录状态的内容。csrfToken的暴露风险
虽然csrfToken本身是用来防CSRF的,但如果被XSS脚本获取到,结合内存中的accessToken,攻击者可以直接发起带合法令牌的API请求,绕开部分防护。
最佳替代实践
1. accessToken:内存存储+自动刷新机制
绝对不要存在localStorage/sessionStorage(XSS易窃取),推荐用内存存储(Context/Zustand/Redux),同时配合refreshToken自动刷新逻辑:
- 页面加载时,检查内存中是否有accessToken,没有就调用刷新接口,用HTTP-only的refreshToken换取新的accessToken和csrfToken,再存入内存。
- 给accessToken设置过期时间,提前触发刷新,避免用户操作时令牌失效。
2. csrfToken:非HTTP-only Cookie+内存备份
csrfToken需要被客户端JS读取,用来在POST/PUT/DELETE请求中携带(比如放在X-CSRF-Token请求头),所以可以:
- 把csrfToken存在非HTTP-only、Secure、SameSite=Strict的Cookie里,既可以被JS读取,又能防CSRF。
- 同时存入内存状态,方便组件快速调用,避免每次都从Cookie读取。
3. 利用Next.js Middleware统一处理认证
用Middleware拦截所有请求,统一处理令牌刷新、权限校验:
- 检查请求中的refreshToken Cookie,自动刷新accessToken。
- 把用户信息注入到请求中,让SSR页面也能获取到认证状态,避免客户端状态不一致。
4. 直接用Auth.js(原NextAuth.js)
官方封装的认证库,已经帮你处理了令牌存储、CSRF防护、自动刷新、SSR兼容等所有细节,不用自己造轮子,适合绝大多数场景。
代码示例
用Zustand管理认证状态(含自动刷新)
// store/authStore.js import { create } from 'zustand'; export const useAuthStore = create((set, get) => ({ accessToken: null, csrfToken: null, user: null, // 登录成功后设置令牌和用户信息 setAuthData: ({ accessToken, csrfToken, user }) => set({ accessToken, csrfToken, user }), // 刷新令牌逻辑 refreshToken: async () => { try { const csrfTokenFromCookie = document.cookie.split('; ').find(row => row.startsWith('csrfToken='))?.split('=')[1]; const res = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'X-CSRF-Token': get().csrfToken || csrfTokenFromCookie, }, credentials: 'include', // 自动携带HTTP-only的refreshToken Cookie }); if (res.ok) { const data = await res.json(); set({ accessToken: data.accessToken, csrfToken: data.csrfToken }); } else { // 刷新失败,清除状态并跳转登录页 set({ accessToken: null, csrfToken: null, user: null }); window.location.href = '/login'; } } catch (err) { console.error('令牌刷新失败:', err); set({ accessToken: null, csrfToken: null, user: null }); window.location.href = '/login'; } }, }));
页面加载时自动刷新令牌
// app/layout.js (App Router) 'use client'; import { useEffect } from 'react'; import { useAuthStore } from '@/store/authStore'; export default function RootLayout({ children }) { const { accessToken, refreshToken } = useAuthStore(); useEffect(() => { // 无accessToken时尝试刷新 if (!accessToken) { refreshToken(); } }, [accessToken, refreshToken]); return ( <html lang="zh-CN"> <body>{children}</body> </html> ); }
Middleware拦截认证请求
// middleware.js import { NextResponse } from 'next/server'; export async function middleware(request) { const path = request.nextUrl.pathname; const isPublicPath = path === '/login' || path === '/register'; const refreshToken = request.cookies.get('refreshToken')?.value; // 已登录用户访问登录页,跳转首页 if (isPublicPath && refreshToken) { return NextResponse.redirect(new URL('/', request.nextUrl)); } // 未登录用户访问私有页面,跳转登录页 if (!isPublicPath && !refreshToken) { return NextResponse.redirect(new URL('/login', request.nextUrl)); } // 有refreshToken时自动刷新accessToken if (refreshToken && !isPublicPath) { try { const csrfToken = request.cookies.get('csrfToken')?.value; const res = await fetch(`${request.nextUrl.origin}/api/auth/refresh`, { method: 'POST', headers: { 'X-CSRF-Token': csrfToken }, credentials: 'include', }); if (res.ok) { const data = await res.json(); const response = NextResponse.next(); // 更新csrfToken Cookie response.cookies.set('csrfToken', data.csrfToken, { secure: process.env.NODE_ENV === 'production', sameSite: 'strict', maxAge: 24 * 60 * 60, }); return response; } } catch (err) { return NextResponse.redirect(new URL('/login', request.nextUrl)); } } return NextResponse.next(); } // 匹配所有非API、非静态资源的请求 export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], };
内容的提问来源于stack exchange,提问作者hantoren

