身份认证令牌这个东西,做后端和做前端的几乎天天见。但说句实话,很多团队对它的理解停留在“会用JWT”或者“过期就重新登录”的层面,真到了要自己设计一套令牌体系、排查线上登录态异常的时候,往往要踩不少坑才能补上认知缺口。
这篇文章我就当成一次项目复盘来写,从令牌体系的选型思路、核心原理,到完整落地实现和排查实录,一次性把“身份认证令牌”这件事讲透。不管你是刚接手认证模块的初级开发,还是正在从零搭建统一登录体系的技术负责人,这篇文章都能给你一套可以直接用的实践参考。
1. 身份认证令牌的选型与整体设计思路
1.1 为什么需要令牌,而不是Session
在开始选型之前,得先想明白一个问题:Session到底哪里不行了?
传统的Session方案,服务器把用户登录态存在内存或Redis里,客户端保存一个Session ID,每次请求带上来,服务端查一下这个ID有没有效。这套方案的优点是“状态可控”,服务端可以随时把某个Session下线。但它的代价也很明显——服务端必须保存会话状态,一旦有多个应用实例,Session同步或者集中存储就成了必修课。而且移动端、小程序、第三方开放API这些场景,让客户端带Cookie本来就很别扭。
令牌(Token)方案则完全不同。服务端把用户身份信息、过期时间、权限范围等信息经过数字签名后发给客户端,后面每次请求,服务端只需要验证签名、检查过期时间,不需要保存任何会话状态。这就是所谓的“无状态认证”,天然适合微服务集群和跨域场景。
用一句话来概括就是:Session把状态存在服务端,令牌把状态存在客户端。这个本质区别决定了两种方案的应用边界,也是我们项目里最终采用令牌体系的核心原因。
1.2 令牌选型:JWT还是Opaque Token
令牌本身是泛称,具体落地其实有两种常见形态。
一种是JWT(JSON Web Token),自带用户信息和签名,服务端验签后直接从令牌里读出用户身份,不用查库。适合对性能要求高、用户量大的场景。另一种叫Opaque Token(不透明令牌),就是一个随机字符串,具体含义只有服务端数据库里存的那条记录知道,服务端要拿这个令牌去后台查一次才能确定用户身份。
这两种方案没有绝对的好坏。JWT的好处是服务端无状态、性能好、适合分布式环境;坏处是一旦签发,在过期之前很难主动撤销,除非引入黑名单机制。Opaque Token的优势是随时可以吊销,想踢谁就踢谁,但每次请求都要查一次存储,性能和Redis的稳定性就成了瓶颈。
我个人在实际项目里更偏向“JWT + Refresh Token”的组合:短期的Access Token用JWT,保证性能;长期的Refresh Token用不透明字符串,存Redis,保证随时可控。这套组合既解决了性能问题,又解决了主动下线的问题,下面会详细展开。
1.3 令牌格式与签名算法的选择
如果决定了用JWT,下一个要解决的问题就是格式和算法。JWT由三部分组成:
- Header:声明令牌类型和签名算法
- Payload:存放用户ID、过期时间、签发时间等声明
- Signature:对前两部分的签名
签名算法目前业界主流是RS256(非对称加密)和HS256(对称加密)。我强烈建议用RS256而不是HS256,原因很简单:用RS256时,签发令牌用私钥,验证令牌用公钥,公钥可以放心地交给各个微服务或API网关,即使某个服务被攻破,攻击者也拿不到私钥去伪造令牌。而HS256用的是同一个密钥,任何持有密钥的服务一旦被拿下,整个令牌体系就全完了。
还有一个细节容易被忽略——签名密钥的强度和轮换策略。私钥至少用2048位的RSA密钥对,而且不能写死在代码或配置文件里。我们项目组用的是配置中心的密钥管理服务,支持定时自动轮换,配上公钥的JWKS(JSON Web Key Set)接口,让下游服务动态获取最新公钥。这套机制搭好之后,密钥轮换对业务方完全是透明的。
2. 令牌生命周期与核心原理解析
2.1 Access Token与Refresh Token的分工
整个令牌体系里,Access Token和Refresh Token的分工非常明确。
Access Token是业务请求真正用到的凭证,生命周期短,通常15分钟到2小时。这么设计的原因是:令牌一旦泄露,有效期越长危害越大。短期的Access Token把风险窗口压缩到了最小。
Refresh Token是换取新Access Token的凭证,有效期可以是一周、一个月甚至更长。它的核心价值是“让用户在长时间内不需要重新输入密码”,但又不会让长期凭证直接参与业务请求。
打个比方:Access Token像是酒店的房卡,有效期短,出门时会被消磁。Refresh Token像是你的身份证,房卡失效了,拿身份证去前台重新办一张就行。只要身份证本身没问题,你就不用回家拿户口本。
2.2 过期时间的合理配置
令牌过期时间的配置看起来只是改个数字,实际上牵扯到业务体验和安全风险的平衡。
Access Token如果设置得太短,比如5分钟,用户会频繁遇到“突然要重新登录”的体验;如果太长,比如24小时,令牌泄露后的风险窗口又太大。我们在实践里的经验是:普通后台管理系统的Access Token设为30分钟到1小时,C端App可以放宽到2小时左右,涉及资金或核心数据的操作则建议缩短到15分钟以内。
Refresh Token的有效期则需要考虑业务场景和合规要求。如果是内部的办公系统,7天是个合理的默认值;C端的产品为了降低用户流失,常见做法是30天甚至更长,并用滑动续期策略——用户每次使用App,如果Refresh Token剩余有效期不足一半,就签发一个新的Refresh Token替换掉旧的,这样用户只要持续使用,就一直不用重新登录。
2.3 令牌的存储与传输安全
令牌本身是明文可读的,所以怎么存、怎么传,直接决定了整个认证体系的底线。
客户端存储这块,Web端绝对不要放localStorage或sessionStorage,因为任何XSS脚本都能把它偷走。推荐的方案是放在HttpOnly的Cookie里,让JavaScript无法读取,同时设置SameSite=Lax和Secure属性,阻止跨站请求携带Cookie并强制HTTPS传输。移动端则可以放在系统提供的安全存储区域,比如iOS的Keychain、Android的Keystore。
传输层面的原则就一条:令牌在任何情况下都不能出现在URL的查询参数里。因为URL会被服务器日志、浏览器历史、代理日志各处记录,令牌一旦进了URL就等于泄露。业务请求必须在Authorization请求头里携带Access Token,格式是标准的Bearer <token>。
我见过最常见的安全事故,就是把令牌放localStorage、或者拼在URL里,然后前端被注入了一段脚本,所有登录态被拖走。这一块怎么强调都不为过。
2.4 签发与校验的完整流程
接着把这些概念拼成一条完整的链路。
用户输入账号密码发起登录,认证服务校验通过后生成一对令牌:Access Token和Refresh Token,返回给客户端。客户端把Access Token存内存或Cookie,Refresh Token存安全存储区。
之后的每个业务请求,客户端都要在请求头带上Access Token,网关或业务服务先解签、验签,再检查过期时间,通过后才进入业务逻辑。如果Access Token过期,客户端拿Refresh Token去请求一个刷新接口,认证服务验证Refresh Token有效后签发新的Access Token。如果Refresh Token也过期了,就要求用户重新登录。
这套流程走顺之后,用户侧的体感就是“只要还在活跃使用,就一直不用重新登录”,而服务端又能保证每个短期凭证的安全边界。
3. 从零搭建一套令牌认证体系
3.1 环境准备与依赖选择
理论说了这么多,下面进入实操环节。我以Node.js环境为例,演示一套完整的JWT + Refresh Token认证体系怎么落地。其他语言(Python、Java、Go等)的实现大同小异,核心思路是一致的。
需要准备的基础依赖:
jsonwebtoken:JWT的签发和验证库,是Node生态里最主流的实现express:搭建HTTP服务redis:存储Refresh Token,做好一点还可以维护一个Access Token黑名单crypto:Node内置的加密模块,用来生成安全的随机字符串作为Refresh Token
密钥这块,先用OpenSSL生成一对RSA密钥对,供RS256算法使用:
# 生成私钥,长度2048位 openssl genrsa -out private.pem 2048 # 从私钥中提取公钥 openssl rsa -in private.pem -pubout -out public.pem生产环境里密钥绝对不能放本地文件,这步只是演示。实际应该把私钥放到配置中心、密钥管理服务或者环境变量里,并做好权限管控和轮换机制。
3.2 首个核心环节:签发Access Token
签发Access Token是整套体系最基础的一步,通过jsonwebtoken的sign方法完成。
const jwt = require('jsonwebtoken'); const fs = require('fs'); const privateKey = fs.readFileSync('./private.pem', 'utf8'); const ACCESS_TOKEN_TTL = '30m'; function signAccessToken(userId, role) { const payload = { sub: userId, // subject,表示令牌主体是哪个用户 role: role, // 附加角色信息,供鉴权使用 iss: 'auth-server', // issuer,说明令牌由谁签发 aud: 'api-server' // audience,说明令牌给哪个服务用 }; const options = { algorithm: 'RS256', expiresIn: ACCESS_TOKEN_TTL }; return jwt.sign(payload, privateKey, options); }这里有几个细节值得展开。sub字段是标准声明,用来存用户唯一标识,尽量不要把密码、手机号等敏感信息放进Payload,因为JWT的Payload只是Base64编码,不是加密,任何人拿到令牌都能解码出来。
aud字段也很有用,如果你是网关模式、后面挂了多个业务服务,可以通过约束audience来保证某个服务签发的令牌只有自己能验,防止令牌在服务之间乱用。签出来的令牌可以直接在jwt.io上解码查看,但那个网站是公网工具,生产环境的令牌千万不要贴进去。
3.3 第二个核心环节:签发Refresh Token与存储策略
Refresh Token的设计有两个要点:一是随机性要足够强,二是必须与服务端存储关联。这里就不应该用JWT了,直接用crypto.randomBytes生成一个256位的随机字符串,把用户ID作为值存进Redis,Key就是这个随机串,并设置绝对过期时间。
const crypto = require('crypto'); const redis = require('redis'); const client = redis.createClient(); const REFRESH_TOKEN_TTL = 7 * 24 * 60 * 60; // 7天,单位是秒 async function signRefreshToken(userId) { const refreshToken = crypto.randomBytes(64).toString('hex'); await client.set(`refresh:${refreshToken}`, userId, { EX: REFRESH_TOKEN_TTL }); return refreshToken; }用随机字符串而不是JWT,核心考量是可控性。存储方案上为什么选Redis而不是数据库?因为Redis的键可以天然设置过期时间,不需要跑定时任务去清理过期记录,读取速度也是微秒级,在刷新Token的高频接口下性能完全不是瓶颈。
刷新令牌的存储结构可以做得更细一些,比如加一个设备维度或会话维度,在返回的Refresh Token里关联上设备信息。这样用户可以主动管理自己“在哪些设备登录过”,想踢掉某个设备只需要删掉对应的Redis key,非常直观。
3.4 第三个核心环节:校验鉴权中间件
有了签发逻辑,接下来要在业务服务里加上校验逻辑。写一个Express中间件,拦截所有需要认证的请求,验证Access Token,并把解析出的用户信息挂到请求对象上。
const jwt = require('jsonwebtoken'); const fs = require('fs'); const publicKey = fs.readFileSync('./public.pem', 'utf8'); function authMiddleware(req, res, next) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ code: 40101, message: '缺少访问令牌' }); } const token = authHeader.split(' ')[1]; try { const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'], issuer: 'auth-server', audience: 'api-server' }); // 把用户信息挂到请求对象,后续的业务代码直接用 req.user = { userId: decoded.sub, role: decoded.role }; next(); } catch (err) { if (err.name === 'TokenExpiredError') { return res.status(401).json({ code: 40102, message: '访问令牌已过期' }); } return res.status(401).json({ code: 40103, message: '访问令牌无效' }); } } module.exports = authMiddleware;中间件的价值在于统一收口校验逻辑,所有需要认证的接口只要挂上这个中间件就行,不用在每个接口里自己写一遍验签判断。注意algorithms: ['RS256']这个配置不能省略,否则某些库为了兼容性,会接受none算法签发的令牌,这是历史上很多知名漏洞的根因。
3.5 刷新令牌的完整实现
最后是刷新接口,这也是整个流程里最容易写错的环节。刷新时不仅要验证Refresh Token本身有没有过期,还要检查这个Token是否被吊销过。
app.post('/api/auth/refresh', async function(req, res) { const { refreshToken } = req.body; if (!refreshToken) { return res.status(400).json({ code: 40001, message: '缺少刷新令牌' }); } // 先从Redis里查这个Refresh Token对应的用户ID const userId = await client.get(`refresh:${refreshToken}`); if (!userId) { return res.status(401).json({ code: 40104, message: '刷新令牌无效或已过期' }); } // 查用户当前状态,必要时检查是否被禁用 const user = await db.findUserById(userId); if (!user || user.status !== 'active') { return res.status(401).json({ code: 40105, message: '用户状态异常' }); } // 签发新的Access Token,同时删掉旧的Refresh Token,换一个新的 await client.del(`refresh:${refreshToken}`); const newAccessToken = signAccessToken(user.id, user.role); const newRefreshToken = await signRefreshToken(user.id); res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken, expiresIn: 30 * 60 }); });这里有一个关键设计:刷新时不仅签新的Access Token,连Refresh Token也一并换新。这是“刷新令牌轮换”的标准做法,能有效防止刷新令牌被重放。攻击者就算拿到一个旧Refresh Token,一旦原主先刷新了一次,攻击者手里的旧Token就变成无效了。与此配套的还有一个“刷新令牌重用检测”策略:如果检测到同一个Refresh Token被多次使用,说明很可能已被盗用,此时应该把所有关联的Refresh Token全部吊销。这个机制可以抗住大部分令牌窃取后的重放攻击,强烈建议实现。
4. 常见问题定位与排查实录
4.1 接口突然401的排查路径
令牌体系上线后,最常收到的反馈就是“某个用户说他的请求突然全部401了”。这类问题排查起来是有固定套路的,按下面的顺序走基本能命中80%以上的原因。
第一步,搞清楚401的错误码是哪一个。我们自己在中间件里区分了“缺少令牌”“令牌过期”“令牌无效”三个错误码,不同的错误码对应完全不同的排查方向。第二步,如果是“令牌无效”,优先看服务器的系统时间。JWT的iat和exp都是基于Unix时间戳的,如果签发令牌的服务器和验证令牌的服务器时钟偏差过大,就会出现“明明刚签发的令牌却提示过期”的诡异现象。第三步,检查是不是刚刚做了密钥轮换。RS256模式下,业务服务持有公钥,如果公钥是通过本地文件配置的,轮换后旧文件没有同步清理,就会出现“用新公钥验旧令牌”的兼容性问题,解决思路是公钥缓存需要允许两个版本的公钥并行存在一段时间。
4.2 时钟偏移问题的处理经验
时钟偏移这个问题特别隐蔽,也特别容易误判。分布式环境下,服务器之间的NTP同步偶尔会有几百毫秒到几秒的偏差。当签发服务的时钟比验证服务快了几秒,业务方会偶发401,而且不是每次必现,非常折磨人。
处理方案有两个层面。第一是运维层面,所有服务器必须配置NTP自动校时,这是基础中的基础。第二是代码层面,jsonwebtoken的verify方法可以传入一个clockTolerance参数,允许一定的时间容差:
jwt.verify(token, publicKey, { algorithms: ['RS256'], issuer: 'auth-server', audience: 'api-server', clockTolerance: 30 // 允许30秒的时间偏差 });这个30秒的容差在实际运维中足够覆盖绝大多数时钟同步误差,又不会对安全产生实质性影响。
4.3 客户端时间错误导致的问题
有一种情况很容易被忽略:用户手机或电脑的系统时间不对,也会导致令牌提前或推后失效。JWT的过期校验虽然是以服务端时间为准,但部分前端SDK会在本地做一次过期判断,本地时间快了5分钟,就会出现“明明服务器还没过期,客户端却提前说过期”的现象。
这类问题的排查方法是看客户端报错日志里的过期时间和服务器实际时间的差值,如果差值和用户设备的时间偏差一致,基本就能实锤。前端方案是在SDK层不要依赖本地时间做校验,统一以服务端返回的expiresIn字段和服务端的401响应为准。实测下来,这类问题大多出现在用户手动改过系统时间的设备上,所以前端实现需要尽量容忍“本地时间不准”的场景。
4.4 令牌刷新的并发竞态问题
刷新令牌在高并发场景下还有一个容易踩的坑——竞态条件。比如客户端同时开了三个页面,三个页面都发现Access Token过期了,同时拿着同一个Refresh Token去调刷新接口。这时候如果不做防护,有可能其中一个请求换到了新Token,另外两个拿到的是旧的或者已经失效的Refresh Token,导致其中一个页面被迫重新登录。
解决思路有几个。最简单粗暴的是在前端做刷新请求的去重,用一个全局的Promise保存刷新请求,在刷新完成前,所有页面的刷新调用都复用同一个Promise。后端的兜底方案则是刷新接口返回结果后,旧Refresh Token立刻作废,并在极短时间内允许同一个Refresh Token被并发请求访问(通过Redis的原子操作保证),从而避免偶发重试。
前端方案示例:
let refreshPromise = null; function refreshAccessToken() { if (!refreshPromise) { refreshPromise = axios.post('/api/auth/refresh', { refreshToken: getStoredRefreshToken() }).then(res => { storeTokens(res.data); return res.data.accessToken; }).finally(() => { refreshPromise = null; }); } return refreshPromise; }这套方案在我实际项目中用了很久,基本能消灭掉“多标签页同时刷新导致被迫重新登录”的投诉。
5. 安全加固与后续扩展思路
5.1 常见攻击面与应对手段
令牌体系的安全防护,核心是防四类攻击:XSS、CSRF、重放和令牌泄漏。
XSS的防护核心是让脚本拿不到令牌,所以Web端建议用HttpOnly Cookie存令牌,配合CSP(内容安全策略)限制脚本来源,这是双重保险。CSRF的防护则依赖SameSite属性和校验自定义请求头,因为跨站请求通常无法携带自定义的Authorization头。重放攻击的防护可以记录最近使用过的令牌摘要或序列号,对短时间内重复出现的令牌做阻断。令牌泄漏则没有一劳永逸的办法,只能靠缩短有效期、开启刷新令牌重用检测、以及日志审计来降低损失。
这里列一个速查表,方便团队在做安全Review时有据可查。
| 攻击类型 | 主要危害 | 核心防护手段 |
|---|---|---|
| XSS | 窃取本地令牌 | HttpOnly Cookie + CSP + 输入输出过滤 |
| CSRF | 伪造用户请求 | SameSite=Lax/Strict + 自定义请求头校验 |
| 令牌重放 | 恶意复用旧Token | 刷新令牌轮换 + 重用检测 |
| 令牌泄漏 | 冒充用户身份 | 缩短Access Token有效期 + 强制HTTPS |
5.2 单点登录(SSO)场景下的令牌扩展
如果公司不止一个系统,而是多个业务系统要共用一套登录态,就会遇到令牌体系演进的下一步——单点登录(SSO)。
SSO的经典实现是基于CAS或OAuth2/OIDC协议的票据或授权码流程。核心思路是:用户身份认证集中在一个认证中心完成,认证通过后颁发一个短期票据或授权码,业务系统拿这个票据去认证中心换取自己的Access Token。用户只要在认证中心登录一次,访问其他系统都自动带上了认证态,不需要重复输入密码。
配合令牌体系做SSO,最基本的流程是:用户首次访问某个系统,系统发现未登录,重定向到认证中心;认证中心确认用户已登录,生成一个一次性票据并回跳到业务系统;业务系统拿票据到认证中心换取Access Token和Refresh Token。这个方案的好处是票据有效期极短,例如5分钟,且只能用一次,降低了被截获重放的风险。
5.3 令牌体系的监控与审计
最后补充一个运维侧的实践经验:令牌体系上线后,必须有监控,否则出了问题连排查线索都没有。
需要重点关注三个指标。一是签发成功率,反映认证服务的整体健康度;二是刷新成功率,如果这个指标突降,说明大量Refresh Token失效,可能是Redis数据被清、密钥轮换异常,或者用户活跃度下降;三是401错误分布,按错误码维度统计,如果“令牌无效”类错误突然增多,需要立刻排查密钥和算法配置是否被意外改动。
审计日志要记录每一次签发的用户ID、IP、设备信息和时间戳,刷新操作还要额外记录刷新前后的令牌指纹。不需要存完整令牌,存哈希值就够了。这些日志既是安全事件溯源的重要依据,也是做用户活跃度分析的数据来源。实践中可以每天凌晨跑一次离线分析,把可疑的批量“令牌签发”“令牌刷新”行为拉出来人工复核,风险拦截窗口能缩短到小时级。
令牌认证体系本身的设计模式在这些年已经趋于稳定,短期Access Token配合长期Refresh Token的组合依然是我个人最推荐的实践。真正拉开差距的是细节——密钥怎么管、刷新Token怎么换、异常行为怎么发现和处置。把这些细节逐一落地,身份认证这层地基才算真正打牢了。