资讯详情

双Token认证机制详解:从设计到实现的安全实践

📅 2026/11/2 12:52:01 | 华诺云谱 👁 阅读
双Token认证机制详解:从设计到实现的安全实践
1. 双 Token 认证机制到底解决了什么问题第一次接触双 Token 这个概念是在一个后台管理系统的重构项目里。当时系统用的是单 Token 方案登录后发一个有效期很长的令牌前端存着每次请求带上。表面上看没什么问题直到有一次安全巡检提了个问题这个令牌一旦泄露在过期之前任何人都能拿着它访问系统而且服务端没有任何手段能主动让它失效。这个问题像根刺一样扎在那儿后来我们花了两周时间把整套认证体系换成了双 Token 机制才算把这块心病去掉。所谓双 Token指的是系统在用户登录成功后同时下发两个令牌一个叫访问令牌Access Token一个叫刷新令牌Refresh Token。访问令牌有效期短通常几分钟到半小时专门用来访问业务接口刷新令牌有效期长通常几天到几周唯一的作用是去换取新的访问令牌。这两个令牌分工明确一个负责日常干活一个负责续命配合起来既保证了安全性又兼顾了用户体验。这个机制解决的核心矛盾其实很朴素安全性和便利性天生是对立的。访问令牌有效期设短了安全但用户每隔几分钟就要重新登录一次体验极差设长了体验好但令牌泄露的风险窗口就大。双 Token 的思路是把这对矛盾拆开处理——短令牌管安全长令牌管续命各司其职。用户感知不到频繁登录攻击者拿到的短令牌又很快失效两边都照顾到了。适合谁来参考这份内容如果你正在做 Web 应用、移动端 App 或者前后端分离系统的登录认证模块尤其是那种需要长时间保持登录态、又对安全性有一定要求的场景那双 Token 基本是绕不开的方案。前端开发者需要理解令牌怎么存、什么时候刷新后端开发者需要设计好签发、校验、刷新、吊销的完整链路即便是刚入门的同学把这套机制吃透对理解现代认证体系也有很大帮助。下面我会从设计思路、核心细节、实操实现到踩坑排查把这套机制掰开揉碎讲清楚。2. 整体设计与方案选型拆解2.1 为什么是双令牌而不是单令牌加长有效期很多人第一反应是既然怕令牌过期影响体验那把单令牌的有效期设长一点不就行了我一开始也这么想过但实际推演下来问题很明显。假设令牌有效期设成七天这七天里令牌就是一把万能钥匙谁拿到谁就能进。更麻烦的是服务端对这个令牌没有任何后悔药——用户改了密码、管理员封了账号、设备丢了只要令牌没过期它依然有效。这种发出去就收不回来的特性在安全要求稍高的系统里是不能接受的。双 Token 的精妙之处在于把长期凭证和短期凭证的职责分离。访问令牌短命即使泄露攻击窗口也就那几分钟到半小时刷新令牌虽然长命但它不能直接访问业务接口只能用来换新令牌而且服务端可以对它做严格管控——绑定设备、记录使用次数、支持主动吊销。这就好比门禁卡和临时访客条门禁卡刷新令牌平时锁在抽屉里不轻易示人每次进楼用临时条访问令牌条子用完就作废丢了也不心疼。从实现成本看双 Token 比单 Token 多了一个刷新接口和一套刷新令牌的存储管理但这点成本换来的是可控的安全边界性价比很高。我们当时评估过几种方案纯单 Token 加黑名单、双 Token、以及基于会话的 Cookie 方案。黑名单方案需要每次请求都查一次黑名单表性能开销大会话方案在分布式部署下要解决会话共享问题双 Token 在无状态和可控性之间找到了一个不错的平衡点最终选了它。2.2 访问令牌与刷新令牌的职责边界把两个令牌的边界划清楚是整套机制能不能跑通的前提。我在设计时给它们定了三条铁律后面所有实现都围绕这三条展开。第一条访问令牌只用于鉴权不承担续期职责。它就是个通行证请求进来时校验签名和有效期通过了就放行过期了就返回特定状态码让前端去刷新。它本身不携带任何我能换新令牌的能力这样即使它泄露攻击者也只能在有效期内访问无法延长自己的生命周期。第二条刷新令牌只用于换取新令牌不参与业务鉴权。业务接口一律不认刷新令牌它只能提交到专门的刷新端点。这样一来刷新令牌的使用面被压到最小暴露风险也随之降低。我们甚至在网关层做了拦截任何带着刷新令牌访问业务接口的请求直接拒绝防止有人误用或恶意试探。第三条刷新令牌必须可吊销、可追溯。访问令牌可以做成无状态的 JWT服务端不存但刷新令牌一定要落库记录它的归属用户、签发时间、过期时间、使用状态、绑定设备指纹等信息。这样一旦发现异常能立刻把某个刷新令牌作废甚至把某个用户的全部刷新令牌清掉实现一键下线。这三条边界定下来之后整个数据流就清晰了登录发双令牌业务请求带访问令牌访问令牌过期后前端拿刷新令牌去换新的刷新令牌失效则要求重新登录。每个环节的职责单一排查问题时也容易定位。2.3 令牌格式选型JWT 还是随机串令牌本身用什么格式也是个需要权衡的点。常见的有两种一种是 JWTJSON Web Token自带签名和载荷服务端可以不查库直接校验另一种是纯随机字符串服务端必须查库比对。访问令牌我倾向于用 JWT。原因是它无状态校验时只需验证签名和过期时间不用访问数据库在高并发场景下性能优势明显。JWT 的载荷里可以放用户 ID、角色、签发时间这些非敏感信息业务层拿到令牌就能直接解析出用户身份省去一次查询。但要注意JWT 里绝对不能放密码、手机号这类敏感数据因为它的载荷只是 Base64 编码不是加密任何人拿到都能解开看。刷新令牌我则倾向于用随机字符串配合数据库存储。因为刷新令牌需要支持吊销和一次性使用这些能力天然依赖服务端状态用 JWT 反而别扭——你没法让一个无状态的 JWT 作废除非再维护一张黑名单那就失去了无状态的意义。随机串方案下每次刷新都去库里比对虽然多一次查询但刷新操作的频率远低于业务请求这点开销完全可以接受。这里有个细节值得说刷新令牌最好做成一次性使用的。也就是说每次用它换新令牌时旧的刷新令牌立即作废同时下发一个新的刷新令牌。这种做法叫刷新令牌轮换好处是如果刷新令牌被盗用攻击者用一次之后真正的用户再用就会失败系统能及时发现异常。我们当时就踩过没做轮换的坑后面排查章节会细说。3. 核心细节解析与实操要点3.1 令牌有效期怎么定一组可参考的参数有效期设多少是新手最容易拍脑袋的地方。设太短刷新频繁用户体验和服务器压力都受影响设太长安全性打折扣。我根据几个项目的实际运行数据整理了一组相对稳妥的参考值。令牌类型常见有效期适用场景说明访问令牌15~30 分钟普通业务系统兼顾安全与刷新频率访问令牌5~10 分钟高敏感系统金融、后台安全优先刷新更频繁访问令牌1~2 小时内部工具、低敏感场景体验优先刷新令牌7 天普通 Web 应用一周内免登录刷新令牌30 天移动端 App移动端登录成本高适当放宽刷新令牌1~3 天高敏感系统缩短风险窗口这组数字不是死的核心逻辑是访问令牌的有效期应该短到即使泄露损失也可控刷新令牌的有效期应该长到用户不会频繁感知到登录。我们后台系统最后定的是访问令牌 20 分钟、刷新令牌 7 天运行大半年下来用户基本感知不到令牌刷新的存在安全巡检也没再提问题。还有一个容易被忽略的点刷新令牌的有效期要不要滑动。所谓滑动是指每次刷新时把刷新令牌的过期时间往后延只要用户持续活跃就一直不用重新登录。这个体验很好但风险是刷新令牌理论上可以无限续期。我的做法是设一个绝对过期时间比如首次登录后 30 天无论怎么刷新到这个点必须重新登录。这样既保证了活跃用户的体验又给令牌生命周期封了顶。3.2 令牌存储位置前端到底该放哪令牌存哪里直接关系到 XSS 和 CSRF 两类攻击的防护。这个问题争论很多我把自己实践下来的结论摆出来。访问令牌我一般存在内存变量里配合页面刷新时的静默续期。存内存的好处是脚本读不到持久化存储XSS 攻击者很难直接偷走缺点是刷新页面就丢了需要靠刷新令牌重新换一个。有些团队图省事把访问令牌放 localStorage这确实方便但 localStorage 对 JavaScript 完全开放一旦有 XSS 漏洞令牌直接被读走。如果非要用持久化存储sessionStorage 比 localStorage 稍好至少关掉标签页就清了。刷新令牌最稳妥的是放在HttpOnly Secure SameSite 的 Cookie里。HttpOnly 意味着 JavaScript 读不到它XSS 偷不走Secure 保证只在 HTTPS 下传输SameSite 设为 Strict 或 Lax 可以防住大部分 CSRF。刷新接口从这个 Cookie 里读刷新令牌前端代码根本接触不到它安全性最高。代价是跨域场景下 Cookie 的配置会麻烦一些需要后端正确设置 CORS 的 credentials 相关头。注意如果刷新令牌放 Cookie刷新接口必须做好 CSRF 防护比如校验 Origin 头或者用双提交 Cookie 模式。别以为放了 HttpOnly 就万事大吉CSRF 攻击不需要读取 Cookie它只需要让浏览器自动带上 Cookie 发请求。我们项目最后采用的是访问令牌存内存刷新令牌放 HttpOnly Cookie。前端封装了一个请求拦截器访问令牌过期时自动调刷新接口拿到新令牌后重放原请求整个过程对业务代码透明。3.3 刷新令牌的落库设计几张表几个字段刷新令牌要可吊销、可追溯落库是必须的。我设计的最小可用表结构大概是这样主键用令牌的哈希值注意是哈希不是明文防止库被拖走直接拿到令牌字段包括用户 ID、签发时间、过期时间、绝对过期时间、使用状态、设备指纹、客户端 IP、最后使用时间。存哈希而不是明文是个容易被忽略的安全细节。刷新令牌本质上和密码一样是长期凭证数据库泄露时明文存储等于把所有人的登录态拱手送人。做法是签发时生成随机串把哈希存库明文返回给客户端校验时对客户端传来的令牌做同样的哈希再比对。哈希算法用 SHA-256 就够了不需要加盐慢哈希因为令牌本身熵足够高不存在被彩虹表撞库的问题。设备指纹这块我一般用 User-Agent 加一些客户端特征做个哈希刷新时比对。如果发现刷新令牌被一个明显不同的设备使用可以判定为异常直接吊销并要求重新登录。这个策略要谨慎因为 User-Agent 会变太严格会误伤正常用户我们最后是记录但不强制拦截只在风控系统里作为参考信号。3.4 并发刷新问题同一时刻多个请求同时过期怎么办这是实操中最容易翻车的地方我单独拎出来讲。场景是这样的页面同时发了五个请求访问令牌恰好过期五个请求同时触发刷新结果五个刷新请求一起打到后端。如果刷新令牌是一次性的第一个刷新成功后旧令牌作废后面四个全部失败用户被踢下线。解决办法有两个方向。前端方向是刷新请求加锁用一个 Promise 变量做标志第一个请求触发刷新时把 Promise 存起来后续请求发现已有刷新在进行就等着这个 Promise 的结果而不是各自去刷新。这个方案实现简单我们项目用的就是这个。后端方向是刷新令牌宽限期旧刷新令牌作废后保留一个很短的宽限期比如 30 秒宽限期内重复使用旧令牌也返回同一个新令牌避免并发误杀。两个方案可以叠加使用前端加锁减少无效请求后端宽限期兜底。提示前端加锁时要注意刷新失败的处理。如果刷新接口返回失败比如刷新令牌也过期了要把等待中的请求全部 reject并统一跳转登录页否则会出现部分请求卡死的情况。4. 实操过程与核心环节实现4.1 登录签发一次登录生成两个令牌登录环节的核心是校验凭证后签发双令牌。我用一段伪代码把关键逻辑串起来语言用 JavaScript 风格方便前端同学理解后端换成任何语言思路一致。async function login(username, password, deviceInfo) { const user await verifyCredentials(username, password); if (!user) throw new Error(凭证无效); // 访问令牌JWT短有效期无状态 const accessToken jwt.sign( { userId: user.id, role: user.role }, ACCESS_SECRET, { expiresIn: 20m } ); // 刷新令牌高熵随机串落库存哈希 const refreshToken crypto.randomBytes(48).toString(hex); const refreshHash sha256(refreshToken); await db.refreshTokens.insert({ tokenHash: refreshHash, userId: user.id, issuedAt: Date.now(), expiresAt: Date.now() 7 * 24 * 3600 * 1000, absoluteExpiresAt: Date.now() 30 * 24 * 3600 * 1000, status: active, deviceFingerprint: hashDevice(deviceInfo), }); // 刷新令牌通过 HttpOnly Cookie 下发 setCookie(refresh_token, refreshToken, { httpOnly: true, secure: true, sameSite: strict, maxAge: 7 * 24 * 3600, path: /api/auth/refresh, }); return { accessToken }; }几个关键点解释一下。访问令牌的载荷只放 userId 和 role不放任何敏感信息刷新令牌用 48 字节随机数熵足够高暴力猜解不现实Cookie 的 path 限定在刷新接口路径上减少它被发送到其他接口的机会绝对过期时间单独记一个字段用于封顶滑动续期。4.2 业务请求校验网关层怎么拦业务请求带的是访问令牌一般放在 Authorization 头里格式是Bearer token。校验逻辑放在网关或中间件里流程是取令牌、验签名、验过期、解析载荷、注入用户上下文。JWT 校验是纯计算不查库所以这一步很快。function authMiddleware(req, res, next) { const auth req.headers.authorization || ; const token auth.startsWith(Bearer ) ? auth.slice(7) : null; if (!token) return res.status(401).json({ code: NO_TOKEN }); try { const payload jwt.verify(token, ACCESS_SECRET); req.user { id: payload.userId, role: payload.role }; next(); } catch (err) { if (err.name TokenExpiredError) { return res.status(401).json({ code: TOKEN_EXPIRED }); } return res.status(401).json({ code: TOKEN_INVALID }); } }这里有个设计细节过期和无效要返回不同的错误码。前端收到TOKEN_EXPIRED才知道该去刷新收到TOKEN_INVALID说明令牌本身有问题比如被篡改应该直接清空登录态跳登录页。如果两者混在一起返回前端就没法区分该刷新还是该重新登录。4.3 刷新接口换新令牌并轮换旧令牌刷新接口是整个机制的心脏逻辑要严谨。它从 Cookie 读刷新令牌查库校验通过后签发新访问令牌和新刷新令牌同时把旧的刷新令牌标记为已使用。async function refresh(req, res) { const rawToken req.cookies[refresh_token]; if (!rawToken) return res.status(401).json({ code: NO_REFRESH }); const tokenHash sha256(rawToken); const record await db.refreshTokens.findOne({ tokenHash }); if (!record || record.status ! active) { return res.status(401).json({ code: REFRESH_INVALID }); } if (Date.now() record.expiresAt || Date.now() record.absoluteExpiresAt) { await db.refreshTokens.update(tokenHash, { status: expired }); return res.status(401).json({ code: REFRESH_EXPIRED }); } // 轮换旧令牌作废签发新令牌 await db.refreshTokens.update(tokenHash, { status: used, usedAt: Date.now(), }); const newRefresh crypto.randomBytes(48).toString(hex); await db.refreshTokens.insert({ tokenHash: sha256(newRefresh), userId: record.userId, issuedAt: Date.now(), expiresAt: Date.now() 7 * 24 * 3600 * 1000, absoluteExpiresAt: record.absoluteExpiresAt, // 绝对过期时间不延长 status: active, deviceFingerprint: record.deviceFingerprint, }); const newAccess jwt.sign( { userId: record.userId }, ACCESS_SECRET, { expiresIn: 20m } ); setCookie(refresh_token, newRefresh, { /* 同登录配置 */ }); return res.json({ accessToken: newAccess }); }注意absoluteExpiresAt是继承旧记录的不随刷新延长这就是前面说的封顶逻辑。另外旧令牌标记为used而不是直接删除保留一段时间用于审计和异常检测——如果发现一个used状态的令牌被再次使用基本可以判定令牌泄露应该把该用户所有活跃刷新令牌全部吊销。4.4 前端拦截器让刷新对业务透明前端这块的核心是一个请求拦截器加一个响应拦截器。请求拦截器负责给每个请求带上访问令牌响应拦截器负责识别TOKEN_EXPIRED触发刷新并重放原请求。let refreshing null; async function request(config) { config.headers.Authorization Bearer ${getAccessToken()}; try { return await axios(config); } catch (err) { if (err.response?.data?.code TOKEN_EXPIRED) { // 加锁同一时刻只发一个刷新请求 if (!refreshing) { refreshing doRefresh().finally(() { refreshing null; }); } await refreshing; config.headers.Authorization Bearer ${getAccessToken()}; return axios(config); // 重放原请求 } throw err; } } async function doRefresh() { const res await axios.post(/api/auth/refresh, null, { withCredentials: true, }); setAccessToken(res.data.accessToken); }refreshing这个变量就是前面说的锁。多个请求同时过期时只有第一个真正发起刷新其余的都await同一个 Promise刷新完成后各自重放。withCredentials: true保证 Cookie 会被带上这是刷新令牌放 Cookie 方案的必要配置。5. 常见问题与排查技巧实录5.1 刷新令牌轮换引发的误杀问题这个坑我印象最深。上线初期有用户反馈用着用着突然被踢下线日志里看到大量REFRESH_INVALID。排查下来是并发刷新导致的用户开了多个标签页或者页面同时发多个请求多个刷新请求几乎同时到达第一个把旧令牌标记为used后面的全部失败。当时的解决分两步走。前端加了刷新锁同一页面内的并发刷新被合并后端加了 30 秒宽限期旧令牌在used后 30 秒内再次使用不报错而是返回同一个新令牌把新令牌也缓存起来。宽限期这个设计要小心缓存的新令牌只能给同一个旧令牌用且过期即焚否则会引入新的安全漏洞。改完之后误杀率降到几乎为零。5.2 时钟偏差导致令牌提前过期分布式部署时签发令牌的服务器和校验令牌的服务器如果系统时间不一致会出现令牌刚签发就被判定过期或者过期了还能用的情况。JWT 的exp校验依赖本地时间偏差超过容忍度就出问题。解决办法有两个一是所有服务器接入统一的时间同步服务把偏差控制在秒级以内二是校验时给一点容错窗口比如jwt.verify时设置clockTolerance: 5允许 5 秒偏差。我们两个都做了之后再没出现过时钟相关的诡异问题。这个坑不常见但一旦踩上很难查因为日志里看不出明显异常只能靠对比服务器时间发现。5.3 常见问题速查表现象可能原因排查方向解决思路用户频繁被踢下线并发刷新误杀看刷新接口日志是否有大量 REFRESH_INVALID前端加锁 后端宽限期刷新接口一直 401Cookie 没带上检查 withCredentials、SameSite、path 配置调整 Cookie 属性与跨域配置令牌刚发就过期服务器时钟偏差对比各节点系统时间时间同步 clockTolerance刷新后旧请求仍失败重放时用了旧令牌检查拦截器是否更新了令牌重放前重新取访问令牌移动端登录态丢失Cookie 被系统清理检查客户端 Cookie 持久化策略移动端改用安全存储方案令牌泄露无法止损刷新令牌未落库检查是否有吊销能力补齐落库与吊销接口5.4 几个容易被忽略的实操心得第一个心得登出要真的登出。很多系统登出只是前端清了令牌服务端啥也没做刷新令牌依然有效。正确做法是登出时把当前刷新令牌标记为revoked并清掉 Cookie。更进一步提供登出所有设备功能把该用户所有活跃刷新令牌全部吊销这在设备丢失场景下很有用。第二个心得改密码要联动吊销。用户修改密码后之前签发的所有刷新令牌都应该作废强制所有设备重新登录。这是安全基线但很多项目漏掉了。实现上就是在改密码的事务里把该用户所有active状态的刷新令牌批量置为revoked。第三个心得监控刷新失败率。刷新接口的失败率是个很好的健康指标。正常情况下失败率应该很低如果突然升高可能是前端 bug、时钟问题或者有人在攻击。我们当时配了个简单告警失败率超过阈值就通知后来还真靠它提前发现过一次配置错误。第四个心得别把刷新令牌的有效期设得比业务容忍度还长。有些系统刷新令牌设 90 天甚至一年用户半年不登录回来还能进这在很多场景下是不合适的。刷新令牌的有效期应该和业务的安全要求匹配宁可短一点让用户偶尔重登也别留一个长期有效的后门。这套双 Token 机制我们前后迭代了三版从最初只求能用到后来补上轮换、宽限期、吊销、监控每一步都是被实际问题推着走的。现在回头看最值钱的不是代码本身而是那些边界设计——什么该无状态、什么必须落库、什么要封顶、什么要留宽限这些判断决定了整套机制在真实流量下稳不稳。如果你正准备上手建议先把第 2 章的职责边界和第 3 章的存储设计想清楚这两块定下来后面的实现基本就是水到渠成的事。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑