☰
双Token认证机制详解:从设计到实现的安全实践
2026/11/2 12:52:00 网站建设 项目流程

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 还是随机串

令牌本身用什么格式,也是个需要权衡的点。常见的有两种:一种是 JWT(JSON 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 章的存储设计想清楚,这两块定下来,后面的实现基本就是水到渠成的事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询