
做了几年后端接口开发几乎每个项目都要回答同一个问题怎么让别人无法随意调用你的API最近在给一个多端项目Web端、小程序、移动端共用一个后端做统一登录鉴权时我把会话方案彻底换成了JWT。换完之后的体会是JWT不是解决所有认证问题的银弹但只要你搞清楚了它为什么存在、能做什么、死穴在哪它就是前后端分离架构下最顺手、最易扩展的API保护方案。这篇文章从我这次改造经历出发把JWT的原理、登录签发、请求校验、刷新续期、RBAC授权完整地过一遍——顺带把那些文档里不会写、网上很少有人系统性讲的坑也翻出来。1. 从Session到JWT后端API为什么需要换一种认证方式1.1 Session认证的天花板在哪里传统Web应用里登录流程大家都熟用户输用户名密码服务端校验通过后在服务端内存或Redis里存一份会话记录然后给浏览器种一个sessionId的Cookie。后续请求带上Cookie服务端比对一下会话记录就知道你是谁了。这套模式在单体服务、服务端渲染的时代特别好使逻辑简单、可强制下线、可随时删除会话。但最近几年前后端分离成为主流API要同时服务Web、小程序、AppSession的毛病就一个接一个地冒出来了。首先是多实例部署的会话同步问题。后端开多个实例之后用户第一次请求落在A实例第二次请求负载均衡落在B实例B实例上找不到用户的会话用户就掉线了。解决办法无外乎几种负载均衡做粘滞会话、把Session集中存到Redis、或者做Session复制。都能做但每一种都让你在设计部署架构时多了一堆约束。其次是客户端的适配问题。移动端App和小程序里Cookie不是一个自然存在的概念你要么自己封一层带Cookie的网络库要么去适配平台差异。对于小程序这种环境操作Cookie更是绕了一大圈体验很难受。再一个是CSRF问题。Cookie是浏览器自动携带的你很难区分当前请求是不是用户主动发出的。虽然可以通过SameSite、CSRF Token缓解但这些都是额外的心智负担。1.2 JWT的设计哲学无状态与自包含JWT解决这些问题的出发点是把“你是谁的凭证”完全交给客户端保存服务端不再维护会话记录。服务端只负责两件事——签发时签名、请求时验证签名。签名通过就信任token里携带的声明信息签名不通过直接拒绝。你可以把它理解成一张带防伪章的自助餐券餐券是用户自己拿着的后厨不需要记录“今天发了多少张券”你递过来一张师傅只要核对章是真的就按券面上的套餐标准给你出餐。因为不存会话状态API服务天然就是无状态的水平扩展时不需要考虑Session同步负载均衡随便打散哪个实例接请求都能验证通过。移动端也不用再折腾Cookie在请求头里塞一个token就行。CSRF的困扰也减轻了——token放在自定义请求头里浏览器不会自动附带跨站请求伪造基本无从下手。1.3 JWT并不是银弹边界要先画清楚但是这里必须泼一盆冷水JWT的“无状态”既是优点也是命门。最典型的场景就是强制下线如果用户密码泄露管理员把他的账号禁用Session方案里删掉session即可立刻生效而JWT方案里已签发的token在过期之前一直有效除非你额外做黑名单或者版本号机制否则服务端没有任何办法立刻让它失效。我在第4章会专门展开讲这个问题这里先记住一个结论JWT适合大多数API场景但它牺牲了服务端主动控制会话的能力你要为“主动失效”这件事单独设计补偿方案。再比如单点登录内部系统、需要审计每个会话状态的场景Session方案可能更合适。选型没有绝对的对错看你更在意什么。我用一个表格来对比方便做技术决策时检查维度Session方式JWT方式服务端状态需要存储与同步无状态天然可扩展多端适配Cookie适配麻烦请求头携带跨端通用CSRF风险需要额外防护自定义Header基本免疫强制下线删除Session即刻生效需要黑名单/版本号补偿实现复杂度低生态成熟中刷新与注销需设计适用架构单体、同构应用前后端分离、微服务、多端2. JWT三段结构的核心原理与签名验证2.1 一拆两半看看token里到底装了什么一个JWT长这样用两个点分成三段eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywicm9sZSI6ImFkbWluIiwiZXhwIjoxNzAwMDAwMDAwfQ.sQv...后面是签名第一段叫Header是JSON经过base64url编码后的字符串描述签名算法和类型。解码后大概是{alg:HS256,typ:JWT}第二段叫Payload是所有声明的集合同样经过base64url编码。这里放的可以是用户ID、角色、过期时间等解码后大概长这样{ userId: 123, role: admin, exp: 1700000000, iat: 1699996400 }第三段是签名。签名不是简单地把前两段拼接起来而是把 Header.Payload 作为输入用指定的算法和密钥计算出来的一个摘要。以HS256为例计算过程大致是HMACSHA256( base64urlEncode(header) . base64urlEncode(payload), secret )任何人只要拿到密钥就能重新计算签名而攻击者如果篡改了payload里的userId签名会对不上服务端一验证就露馅。2.2 签名算法的选型HS256、RS256与ES256JWT支持多种签名算法主流的是HS256、RS256、ES256三类选型逻辑差异很大。HS256是HMAC对称签名签发和验证用的是同一个密钥。系统内部各服务之间做认证时最省事密钥管理简单性能和实现成本都低。缺点是密钥一旦泄露谁都能签发合法token多服务之间也得共享同一个密钥密钥分发的面比较广。RS256和ES256是非对称签名签发用私钥验证用公钥。在多服务架构里只有一个认证服务持有私钥其他API服务只持有公钥。公钥分发到各服务即使某台业务服务器被攻破攻击者拿到公钥也签不出新token。开放平台面向第三方提供API时一般也推荐用非对称算法配合公钥做token校验。社区里一个大原则内部系统用HS256配一个足够强的密钥完全够用跨服务边界、开放平台、第三方接入的场景优先上RS256。密钥强度上HS256的密钥至少要32个随机字节别用什么secret、password这种字典词。2.3 签名能防篡改但它不加密这里有个高频误区必须强调JWT的Payload只是base64url编码不是加密。你用jwt.io这类在线工具粘贴一个token立刻就能看到明文内容不需要任何密钥。所以严格来说token里的声明信息不要放密码、身份证号、手机号这类敏感数据任何能唯一定位用户身份的隐私信息都要谨慎。一般放userId、角色、必要的会话标识就够了其他敏感信息宁可每次请求时去数据库取也不要图省事塞进token。验证签名的作用是保证“内容没有被篡改”而不是保证“内容别人看不到”。这一点想通了很多安全问题能提前避免。3. 认证流程落地从登录签发到请求校验的完整链路3.1 登录接口签发一个经过签名的token先看最简单的核心逻辑。我用Node.js生态里最常用的jsonwebtoken库来演示你用PyJWT、其他语言的类似库思路完全一致。登录时先用账号密码验证用户身份验证通过后生成token返回const jwt require(jsonwebtoken); app.post(/auth/login, async (req, res) { const { username, password } req.body; const user await db.findUserByUsername(username); const passwordOk await bcrypt.compare(password, user.passwordHash); if (!user || !passwordOk) { return res.status(401).json({ message: 用户名或密码错误 }); } const token jwt.sign( { userId: user.id, role: user.role, }, process.env.JWT_SECRET, { expiresIn: 2h, algorithm: HS256, jwtid: crypto.randomUUID(), // 每个token一个唯一ID后面拉黑会用 } ); res.json({ token, tokenType: Bearer, expiresIn: 7200 }); });这里有几个习惯我建议一开始就养成token里只放定位用户所需的最小集合userId role基本够了。jwtid也就是jti声明一定要加。它没有体积成本但后面做黑名单、做token重用检测全靠它。expiresIn时间要单独决策。我见过很多项目直接设7天后面发现改密码都踢不掉旧token问题变得非常难收场。access token我一般给15分钟到2小时配合第4章的刷新机制。密钥必须来自环境变量绝对不能硬编码在代码里。3.2 token放在哪里Authorization头是首选签发出来的token客户端放在哪里这是很多新手会纠结的问题。直接给结论对API服务放在Authorization: Bearer token请求头里。原因是这是业界标准服务端解析逻辑统一浏览器不会自动附带自定义头CSRF风险天然低多端场景下前端代码里手动塞header是最可控的方式。放Cookie里当然也能做你需要处理跨域、CSRF防护、以及小程序环境的不便。放URL参数里是最糟糕的选项——URL会进访问日志、网关日志、浏览器历史记录token等于裸奔。推荐的做法是前端在请求拦截器里统一加const api axios.create({ baseURL: /api }); api.interceptors.request.use(config { const token localStorage.getItem(accessToken); if (token) { config.headers.Authorization Bearer ${token}; } return config; });关于access token存localStorage还是内存后续第6章再展开讨论。3.3 后端校验中间件把“你是谁”落到req对象上服务端校验token核心就一个中间件把它写清楚整个API的认证框架就立起来了function authenticate(req, res, next) { const header req.headers.authorization || ; if (!header.startsWith(Bearer )) { return res.status(401).json({ message: 未提供认证令牌 }); } const token header.slice(7); try { const payload jwt.verify(token, process.env.JWT_SECRET, { algorithms: [HS256], }); req.user payload; next(); } catch (err) { if (err.name TokenExpiredError) { return res.status(401).json({ message: token已过期, code: TOKEN_EXPIRED }); } return res.status(401).json({ message: token无效 }); } }这个中间件做了三件事从Authorization头取出token、用密钥验证签名和过期时间、把验证通过的payload挂到req.user上后续接口只要在路由上挂这个中间件就能拿到当前用户的身份信息。两个细节容易踩坑第一algorithms: [HS256]必须显式指定。很多JWT库的verify操作默认不限制算法攻击者如果把Header里的alg改成none或者改成服务端用的其他算法就可能绕过验证。关于这一点第6章专门讲。第二过期和被篡改要区分处理。过期token的err.name是TokenExpiredError业务上可以让前端走刷新流程其他校验失败则说明token本身有问题直接要求重新登录。3.4 一条完整的请求链路是什么样的把上面几块串起来看一个请求的生命周期用户在小程序里输入密码登录 → 后端验证成功签发token → 小程序把token存在本地 → 每次请求前拦截器加上Authorization头 → 后端中间件验证签名和有效期 → 验证通过接口逻辑使用req.user取到用户ID和角色 → 返回数据。如果token过期中间件返回401前端收到特定错误码后请求刷新接口拿新token重新发起原请求。这一条链路在每个项目里长得都差不多跑通一次后面所有的API受保护接口都复用同一套逻辑。4. Token的过期、刷新与失效率控制4.1 为什么单token方案会逼疯用户上节里的登录接口签发的access token有效期为2小时问题来了用户2小时后继续操作没有弹窗请求直接401失败。如果access token设成7天用户倒是舒服了但token一旦泄露攻击者能持续访问7天而且服务端想踢都踢不掉。综合考虑体验与安全业界通用的做法是access token refresh token双层结构access token有效期短15分钟到2小时负责日常API访问。refresh token有效期长7天到30天只用于换取新的access token。这样设计的安全逻辑是access token暴露概率最高但它的生命周期很短即使泄露攻击窗口有限refresh token生命周期长但它只在刷新接口上使用不参与每个请求暴露面小。同时refresh token尽量放在HttpOnly Cookie里JS拿不到抗XSS能力更强。4.2 refresh token的签发与轮换登录接口现在要返回两个tokenconst accessToken jwt.sign( { userId: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: 15m, jwtid: crypto.randomUUID() } ); const refreshToken jwt.sign( { userId: user.id, tokenType: refresh }, process.env.JWT_REFRESH_SECRET, { expiresIn: 7d, jwtid: crypto.randomUUID() } ); res.setHeader(Set-Cookie, refreshToken${refreshToken}; HttpOnly; Path/auth/refresh; Max-Age604800; SameSiteLax); res.json({ accessToken, expiresIn: 900 });注意refresh token的密钥跟access token区分开payload里加一个tokenType: refresh声明防止有人拿refresh token去当access token用。刷新接口的实现逻辑是app.post(/auth/refresh, async (req, res) { const oldRefreshToken req.cookies.refreshToken; if (!oldRefreshToken) { return res.status(401).json({ message: 缺少刷新令牌 }); } try { const payload jwt.verify(oldRefreshToken, process.env.JWT_REFRESH_SECRET, { algorithms: [HS256], }); const user await db.findUserById(payload.userId); const newAccessToken jwt.sign( { userId: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: 15m, jwtid: crypto.randomUUID() } ); res.json({ accessToken: newAccessToken, expiresIn: 900 }); } catch (err) { return res.status(401).json({ message: 刷新令牌无效 }); } });这里再强调一个进阶策略每次刷新时给用户签发新的refresh token旧refresh token立即作废并记录旧token的jti。如果检测到同一个jti被再次使用说明refresh token可能被复制或泄露了这时候应该把该用户的所有refresh token全部作废要求重新登录。这就是“refresh token轮换”的常规做法能把token泄露后的影响面控制到一次窃取窗口内。不过轮换实现时会遇到并发刷新问题。比如用户同时开两个标签页两个页面各自拿旧refresh token去刷新严格的轮换会让其中一个失败。实际项目中要么在刷新逻辑里允许新旧refresh token在非常短的时间内并存要么按用户维度做并发锁。这块我推荐的做法是Redis里存一个refresh:{userId}的版本号或当前有效jti刷新时比对这样做既能轮换也能控制并发。4.3 注销、黑名单和强制下线无状态token的补偿方案JWT无状态意味着“让token立即失效”这件事默认做不到。常见的补偿方案有三种按推荐程度排方案一是黑名单。把需要提前作废的token的jti存进RedisTTL设为token剩余有效期。每次API请求中间件在verify之后查一下Redis命中就拒绝。适合单点注销、账号临时封禁等低频操作实现成本低。方案二是token版本号。在用户表加一个token_version字段签发token时把版本号放进payload。每次强制下线、改密码、封号时token_version旧token的payload里的版本号跟数据库对不上自然失效。这个方案对“全体token即时失效”的场景特别有效比如用户修改密码后希望所有旧会话失效版本号1就搞定。方案三是短生命周期自动刷新。把access token设成15分钟refresh token也设成较短时长服务端只需要处理refresh阶段的主动失效日常API请求阶段不查任何额外存储。这个方案性能最好代价是用户体验和失效生效之间有个小窗口。我在生产项目里的偏好是方案二加方案三相组合默认API走短生命周期关键安全操作改密、封号走版本号1全量失效快速生效。黑名单作为一种兜底手段只在需要临时封禁某个具体token时使用。4.4 时钟偏移与多实例部署的一致性有一个不起眼的坑JWT的exp和nbf都是时间戳验证时依赖服务器当前时间。如果API服务的系统时间不准或者跟签发token的服务器之间有几秒钟误差就会出现“token明明没过期服务端却判定过期”或者反过来“已到nbf时间却还判未生效”。多数JWT库提供了一个叫clockTolerance的参数验证时允许一个小的误差范围。生产环境建议设置5到10秒jwt.verify(token, secret, { algorithms: [HS256], clockTolerance: 10, });同时运维层面要确保所有服务器开启了NTP时间同步。这些细节平时不起眼出了线上问题排查起来非常费劲。5. 从认证到授权基于JWT轻量实现RBAC权限模型5.1 认证与授权两件事别混为一谈认证回答“你是谁”授权回答“你能做什么”。很多接口只有一个authenticate中间件校验完token就把用户放行这属于只做了认证、没做授权。比如普通用户登录后完全可以调用管理员删除接口虽然他的身份是真实的但他没有这个操作权限。正确的做法是每层接口先认证身份再做权限判断。授权策略里最常用的模型是RBAC基于角色的访问控制。核心思路是三个概念用户、角色、权限。用户属于角色角色拥有权限接口只校验权限。5.2 在Token里声明角色与权限最轻量的做法是登录时把角色放进payloadjwt.sign( { userId: user.id, role: admin }, process.env.JWT_SECRET, { expiresIn: 15m, jwtid: crypto.randomUUID() } );服务端做一次角色判断就够了。如果系统权限粒度比较细比如“可以创建订单”和“可以删除订单”是不同权限可以在payload里放一个permissions数组{ userId: 123, role: editor, permissions: [post:create, post:update] }但这里的取舍要清醒token是每次请求都携带的payload越大每个请求的带宽和解析开销越大。权限列表如果超过十几项建议改为permission code放Redis或者只放role由服务端查一次权限表。小系统的权限列表通常就是个位数放payload里完全没问题。5.3 一个轻量RBAC中间件的实现在authenticate确认用户身份之后加一个授权中间件function requirePermission(permission) { return (req, res, next) { if (!req.user) { return res.status(401).json({ message: 未认证 }); } const userPermissions req.user.permissions || []; if (!userPermissions.includes(permission)) { return res.status(403).json({ message: 权限不足 }); } next(); }; }路由上组合使用// 需要登录且拥有删除订单权限 app.delete(/api/orders/:id, authenticate, requirePermission(order:delete), deleteOrderHandler );这里HTTP状态码的语义也要注意未认证返回401已认证但权限不够返回403。不少人全部返回401前端拿到错误后一律跳登录页导致“权限不足”的用户被当成游客踢掉体验很差。5.4 角色模型设计权限表要留出演进空间给一个最小但可以扩展的表结构设计参考roles表id、role_codeadmin/editor/user、name permissions表id、permission_codeorder:create / order:delete、name role_permissions表role_id、permission_id用户表里加一个role_id字段登录时把该角色对应的权限列表查出后放进token。后端接口需要的所有权限判断都基于permission_code而不是角色名。这样后续给某个角色加权限只需要改权限映射不用动接口代码。数据级别的权限归属也要在接口里做。比如用户可以调用order:update权限的接口但不代表他能改所有人的订单。常见的做法是接口内部校验order.userId req.user.userId这个逻辑属于数据权限层用JWT只解决了身份级别和功能权限级别的问题别指望一个中间件全包了。6. 那些让JWT形同虚设的安全坑6.1 弱密钥和硬编码是头号大敌JWT的安全性完全建立在密钥上。如果密钥是secret、123456这种弱值攻击者随便拿个字典就能把签名爆破出来然后伪造任意身份。我见过不止一个项目把JWT_SECRETsecret提交到代码仓库里这种部署等于没认证。正确做法密钥至少32字节随机数用环境变量或密钥管理服务存储绝对不能进版本库。更严谨一点在token的Header里加kid声明用它标识当前token使用的是哪个密钥这样生产环境可以做到定期轮换密钥而不影响所有存量token。6.2 算法混淆攻击为什么必须显式指定algorithms这是JWT历史上著名的一类漏洞。攻击者把Header里的alg改成none把Payload改成想要的用户重新编码。如果服务端校验时用了“先取header里的alg再决定怎么验签”的旧实现就可能直接放行不验证签名。类似的问题还有服务端用的是RS256非对称验签攻击者把alg改成HS256服务端如果仍然信任header的alg就会用公钥当HMAC密钥来验证签名——而公钥攻击者早就拿到了能轻松伪造合法token。防御方式就是上面代码里写的verify时显式传入algorithms: [HS256]或[RS256]。这一步不能省各种主流JWT库最新的安全实践都要求固定算法列表。选了哪种算法就只接受那种header里声明的alg值只作为参考不作为验证依据。6.3 Token被窃取的常见路径与对应的堵法JWT实际被攻破大多数不是密码学层面的破解而是token在传输、存储环节被偷。最常见的路径token拼在URL里被Nginx日志、网关日志、第三方统计脚本记下来token放localStorage页面被注入XSS脚本后直接读走在公网HTTP明文传输中间人直接截获。对应措施其实不复杂但每一件都要做到位全站HTTPS、token绝不进URL、日志脱敏不打印Authorization头、前端尽量把access token放在内存里或最少权限的存储中refresh token放HttpOnly Cookie。再配合短有效期和刷新轮换即使某个token泄露几分钟后窗口也会关闭。6.4 复盘一次典型的“token时效失控”事故最后讲一个典型事故的复盘。某项目上线的第一版access token有效期设了7天refresh token没做。上线后某天发现用户修改密码后旧token仍然可以访问接口一查才发现改密码的逻辑根本没有处理已签发的token要等7天token过期旧token才会失效。这个局面非常尴尬。后来改造思路是这样的引入token版本号字段用户表新增token_version改密码和封号时版本号1然后access token有效期缩短到30分钟refresh token改为7天并存在HttpOnly Cookie里。改造完成后“改密即踢人”“封号立即生效”都变成了几行代码的事。这个例子我想说的是很多安全问题的根子不是技术不会而是设计阶段没有把“token失效时机”当成一个重要需求来对待。安全不是一个开关而是从签发到失效的整个生命周期管理。最后再分享一个我自己的调试习惯开发环境里我会在token的payload里临时塞一个debug: true字段拿到token后直接去jwt.io看是不是自己预期的内容排完错再删掉这个字段。另外认证中间件的逻辑一定要单独抽出来写单元测试把“无token”“token过期”“token被篡改”“alg不匹配”“权限不足”这些case各写一个用例。前期花一小时后面能排除掉大量线上问题。这次改造项目里我就是靠这些测试把各种边界情况提前堵死的到了上线阶段认证相关的故障率几乎为零。