基于JWT与刷新令牌的认证及持久化登录方案咨询
问题解答
一、你的认证方案是否可行?
完全可行,这是业界非常成熟的标准认证模式,所谓的矛盾观点大多是对细节防护不到位的担忧,只要做好以下几点就能规避风险:
- 短生命周期JWT:有效降低JWT泄露后的被滥用窗口,即使被盗用,有效期短也不会造成长期危害
- 刷新令牌存储:存在HttpOnly、Secure、SameSite=Strict的Cookie中,能避免前端JS读取,从根源防止XSS窃取;同时刷新令牌存在数据库,支持主动吊销(比如用户改密码、登出其他设备),解决了JWT无法主动失效的痛点
- 额外防护:给刷新令牌设置合理的有效期(比如7天),同时服务端验证刷新令牌时要关联用户ID,防止跨用户复用
二、如何优雅处理JWT自动刷新,避免重复代码?
你提到的两个方案都有明显弊端:方案1每次请求带双令牌冗余且增加服务端校验成本;方案2每个thunk前置检查会导致大量重复逻辑。结合React + tRPC + Redux Toolkit的技术栈,最优解是在tRPC请求链路的全局拦截层统一处理刷新逻辑,具体实现步骤如下:
核心思路
利用tRPC的请求拦截器(interceptor)+ 全局刷新锁,实现一次编码、全局生效的自动刷新,同时避免并发请求重复触发刷新。
具体实现
- 客户端JWT过期判断
用jwt-decode解析Redux store中的JWT,提取exp字段判断是否即将过期(比如提前30秒触发刷新,避免请求过程中过期):
import jwtDecode from 'jwt-decode'; const isTokenExpired = (token) => { if (!token) return true; const { exp } = jwtDecode(token); // 提前30秒判定为过期,预留刷新时间 return Date.now() >= (exp * 1000) - 30000; };
- tRPC全局请求拦截器
在创建tRPC客户端时添加请求拦截器,统一处理JWT刷新逻辑,同时用一个全局Promise锁解决并发请求重复刷新的问题:
import { createTRPCReact } from '@trpc/react-query'; import type { AppRouter } from '../server/router'; const trpc = createTRPCReact<AppRouter>(); // 全局刷新锁:避免多个并发请求同时触发刷新 let refreshPromise: Promise<string | null> | null = null; const trpcClient = trpc.createClient({ links: [ // 自定义请求拦截链路 (runtime) => ({ async op(op) { const token = store.getState().auth.accessToken; let currentToken = token; // 如果令牌过期且没有正在进行的刷新请求 if (isTokenExpired(currentToken) && !refreshPromise) { refreshPromise = fetch('/api/auth/refresh', { method: 'POST', credentials: 'include', // 自动携带HttpOnly Cookie中的刷新令牌 }) .then(async (res) => { if (!res.ok) { // 刷新令牌失效,清除本地状态 store.dispatch(authActions.logout()); return null; } const data = await res.json(); // 更新Redux store中的新JWT store.dispatch(authActions.setAccessToken(data.accessToken)); return data.accessToken; }) .finally(() => { refreshPromise = null; }); } // 如果有正在进行的刷新请求,等待其完成 if (refreshPromise) { currentToken = await refreshPromise; } // 如果最终没有有效令牌,直接返回401错误 if (!currentToken) { return runtime.error({ error: { message: 'Unauthorized', code: 'UNAUTHORIZED', }, op, }); } // 携带有效令牌发起原请求 return runtime.next({ ...op, context: { ...op.context, headers: { ...op.context.headers, Authorization: `Bearer ${currentToken}`, }, }, }); }, }), // 后续的HTTP链路(比如httpLink) ], });
- Redux Toolkit状态管理
在Auth Slice中处理JWT的更新和登出逻辑:
import { createSlice } from '@reduxjs/toolkit'; const authSlice = createSlice({ name: 'auth', initialState: { accessToken: localStorage.getItem('accessToken') || null, // 可选:也可以只存在内存,页面刷新时用刷新令牌重新获取 user: null, }, reducers: { setAccessToken: (state, action) => { state.accessToken = action.payload; // 如果需要持久化到localStorage(注意XSS风险,可选) localStorage.setItem('accessToken', action.payload); }, setUser: (state, action) => { state.user = action.payload; }, logout: (state) => { state.accessToken = null; state.user = null; localStorage.removeItem('accessToken'); // 可选:调用服务端接口吊销刷新令牌 fetch('/api/auth/logout', { credentials: 'include' }); }, }, }); export const { setAccessToken, setUser, logout } = authSlice.actions; export default authSlice.reducer;
- 服务端/refresh接口逻辑
验证Cookie中的刷新令牌,查询数据库确认有效后,生成新的短生命周期JWT返回:
// 示例:Node.js + Express的refresh接口 app.post('/api/auth/refresh', async (req, res) => { const refreshToken = req.cookies.refreshToken; if (!refreshToken) return res.sendStatus(401); try { // 从数据库查询刷新令牌是否有效,关联用户ID const tokenRecord = await db.refreshTokens.findUnique({ where: { token: refreshToken }, include: { user: true }, }); if (!tokenRecord || tokenRecord.expiresAt < new Date()) { // 刷新令牌无效或过期,清除Cookie res.clearCookie('refreshToken', { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict', }); return res.sendStatus(401); } // 生成新的短生命周期JWT const newAccessToken = jwt.sign( { userId: tokenRecord.user.id }, process.env.JWT_SECRET, { expiresIn: '15m' } ); // 可选:刷新令牌滚动更新(延长有效期) await db.refreshTokens.update({ where: { token: refreshToken }, data: { expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) }, }); res.json({ accessToken: newAccessToken }); } catch (err) { res.sendStatus(500); } });
方案优势
- 无重复代码:全局拦截器统一处理所有tRPC请求的刷新逻辑,无需在每个thunk或接口中重复编写
- 并发安全:全局刷新锁避免多个请求同时触发刷新接口,减少服务端压力
- 侵入性低:业务代码无需感知认证逻辑,专注于业务实现
- 体验流畅:用户无感知自动刷新令牌,不会因为JWT过期导致请求失败
内容的提问来源于stack exchange,提问作者branperr
相关产品推荐
相关产品推荐

