
我们团队最近刚把一个老项目的 Session 认证全部换成了 JWT前后踩了大概一周的坑从登录签发、中间件校验、续签策略到安全加固都过了一遍。今天就把这套东西完整梳理出来重点讲实现细节和那些文档里不会写的坑希望能让正在做 API 认证的朋友少走弯路。先说结论如果你的 API 需要支持多端Web、小程序、App、需要水平扩容、或者想减轻服务端 session 存储压力JWT 是目前非常划算的选择。这篇文章面向的是有一定后端基础、想把认证体系做扎实的开发者我会从原理讲到实战最后附上我实测过的问题排查清单。1. 为什么API认证要选JWT1.1 Session认证遇到了什么瓶颈很多人第一次接触用户认证是从 Session 开始的。客户端把用户名密码发过来服务端校验通过后生成一个 session_id存到内存或者 Redis 里然后把 session_id 种到 Cookie 返回给前端。之后每次请求浏览器自动带上 Cookie服务端查一下 session 是否存在就知道是谁在调用。这套流程在单体应用时代没有任何问题但一旦系统开始长大麻烦就来了。首先session 数据放在服务端内存里多实例部署时得额外引入 Redis 做共享存储不然用户在 A 实例登录了请求被负载均衡转发到 B 实例就直接 401。其次移动端 App 对 Cookie 的支持并不像浏览器那么顺手你得手动管理 header、手动拼接 Cookie 字符串体验很割裂。我当时最头疼的场景是产品要上小程序又要保留 Web 端还要开放接口给合作方调用。Session 那一套在 Web 端还行到了小程序和非浏览器客户端就非常别扭。这让我下定决心换 JWT。1.2 JWT解决了什么问题JWTJSON Web Token的核心思路是把用户身份信息直接打包进一个自包含的令牌里由服务端签名后发给客户端。之后客户端每次请求把令牌放在 Authorization header 里带回来服务端只需要验证签名和有效期不需要查数据库、不需要查缓存就能确认请求者是谁。它带来的直接好处有三个无状态化令牌本身携带了用户 ID、过期时间等信息服务端不存 session水平扩容变得非常简单加机器就行了。跨端友好JWT 就是一个纯字符串放在 header 里传不管是 Web、小程序、App 还是第三方调用规则完全一致。解耦认证与业务认证逻辑集中在签发和校验两个环节业务接口只需要关心这个用户有权限做什么不需要关心这个用户是谁。不过这里我必须说一句实话JWT 不是银弹。它有自己的短板比如无法主动失效、令牌体积偏大、刷新机制比 session 复杂。后面我会专门讲这些坑怎么规避。2. JWT的核心原理拆解2.1 三段式结构一眼看懂第一次看到 JWT 的人都会觉得那串字符串很神秘其实拆开看特别简单。一个完整的 JWT 长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c用英文句点分成三段Header头部、Payload载荷、Signature签名。Header 是一个 JSON 对象通常包含两部分令牌类型typ和签名算法alg。Payload 里放的就是你想声明的用户信息比如用户 ID、用户名、角色、过期时间等。这里的声明有一组约定俗成的字段比如 sub主题通常存用户 ID、iat签发时间、exp过期时间、iss签发者等。第三段签名的生成逻辑是把前两段的内容拼接后加上一个只有服务端知道的密钥用 Header 里声明的算法计算出来。生活化类比JWT 就像一个盖了公章的信封。信封上写了你是谁、什么时候签发、什么时候过期信封口有服务端用私章压的封泥。任何人拿到这个信封都能看到里面的字Payload 是 base64 编码不是加密但想伪造一个没被盖过章的信封就会立刻露馅。2.2 签名算法怎么选HS256还是RS256这是很多新手第一个纠结的点。HS256 是对称算法加密和解密用同一个密钥。RS256 是非对称算法私钥签发、公钥验证。我个人的建议是绝大多数自用项目用 HS256 就行实现简单一个 secret 搞定如果是有纯前端和后端完全分离、或者有多个服务需要独立验证令牌的场景优先用 RS256因为公钥可以安全地分发给各个服务私钥只保存在认证中心一个地方。HS256 的常见安全隐患是密钥泄露。一旦 secret 被拿到攻击者可以伪造任意用户的令牌。所以密钥一定要放环境变量不能写死代码里也不能提交到 Git 仓库。RS256 也有坑后面讲漏洞时我会专门说 JWT algnone 的攻击方式。2.3 为什么不能把敏感信息放进PayloadPayload 虽然被签名保护但它本身只是 base64 编码任何人把令牌拷到浏览器控制台里就能解码看到内容。所以密码、手机号、身份证号这类敏感信息绝对不能放进去。我之前见过一个项目把用户明文手机号放在 JWT 里做展示用结果前端调试时有人复制了 token 去 jwt.io 上解析手机号全暴露了。正确做法是Payload 里只放用户 ID、昵称、角色这类非敏感标识信息需要用户敏感资料时再按 ID 去库里查。3. 从零实现登录接口与JWT签发3.1 环境准备与项目结构我用 Node.js Express 来演示因为这是我踩坑的主要环境而且是前端工程师最容易上手的一套组合。你如果是 Java、Go、Python 技术栈思路完全一致只是库的 API 略有差别。建议按这样的目录来组织认证相关代码后面加功能时不会一团乱麻src/ middlewares/ auth.js // JWT 校验中间件 permission.js // 权限校验中间件 modules/ auth/ auth.controller.js auth.service.js auth.routes.js utils/ jwt.js // 签发和校验封装依赖用这三样就够了jsonwebtokenJWT 核心库、expressWeb 框架、dotenv读取环境变量。如果后面想顺手做密码加密再加一个 bcryptjs。3.2 签发令牌的核心代码登录接口的流程很固定接收账号密码、校验用户是否存在且密码是否正确、签发 JWT 返回给前端。这里我把签发逻辑封装成独立函数因为后面做刷新、做记住我功能时都要复用。// utils/jwt.js const jwt require(jsonwebtoken); const JWT_SECRET process.env.JWT_SECRET; const JWT_EXPIRES_IN process.env.JWT_EXPIRES_IN || 2h; function signToken(user) { // 只放必要的信息千万别塞密码 const payload { sub: user.id, username: user.username, role: user.role, }; return jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, issuer: your-app-name, }); } function verifyToken(token) { try { return jwt.verify(token, JWT_SECRET, { issuer: your-app-name, }); } catch (err) { return null; } } module.exports { signToken, verifyToken };登录时比对密码的逻辑我就不展开写完整代码了核心是数据库里的密码永远只存哈希值校验时用 bcrypt.compare 来比对。拿到合法用户后调 signToken 生成令牌返回。这里有个小细节签发令牌里加 issuer 参数并保持校验一致能防止别人拿其他系统的令牌来你的接口冒充这个习惯建议一开始就养好。3.3 认证中间件的编写JWT 校验中间件是整个体系里最关键的一环所有受保护的接口都要过它。标准做法是从请求头里取 Authorization按 Bearer 前缀解析出令牌调用 verifyToken 校验校验通过后把用户信息挂到 req 上后续的业务接口直接取用。// middlewares/auth.js const { verifyToken } require(../utils/jwt); function authMiddleware(req, res, next) { const authHeader req.headers.authorization || ; if (!authHeader.startsWith(Bearer )) { return res.status(401).json({ code: 401, message: 缺少认证令牌 }); } const token authHeader.slice(7); const decoded verifyToken(token); if (!decoded) { return res.status(401).json({ code: 401, message: 令牌无效或已过期 }); } // 把用户信息挂到 req 上供后续接口使用 req.user { id: decoded.sub, username: decoded.username, role: decoded.role, }; next(); } module.exports authMiddleware;写这个中间件时我踩过一个很隐蔽的坑Bearer 前缀后面有个空格如果用 split( ) 去切遇到某些客户端格式不标准比如多个空格就会解析出空字符串。所以建议直接用 startsWith 判断加 slice 截取别图省事用 split。3.4 角色权限校验有了认证还不够很多接口需要做授权也就是判断这个接口谁能访问。我习惯在 authMiddleware 之后再串一层 permission 中间件根据 JWT 里的 role 字段做判断。// middlewares/permission.js function permissionMiddleware(...allowedRoles) { return (req, res, next) { if (!req.user) { return res.status(401).json({ code: 401, message: 未认证 }); } if (!allowedRoles.includes(req.user.role)) { return res.status(403).json({ code: 403, message: 没有权限执行此操作 }); } next(); }; } module.exports permissionMiddleware;使用方式非常简洁管理员接口挂一个 permissionMiddleware(admin)普通用户接口挂一个 authMiddleware 就够。注意 401未认证和 403无权限语义完全不同前者是你是谁我不知道后者是我知道你是谁但你不许进排查问题时看状态码就能快速定位别混用。4. Token续签与刷新策略4.1 过期时间怎么定才合理JWT 最大的问题之一是无法主动让令牌失效。假设令牌有效期设置成 7 天用户中途改了密码、被封号、或者被踢下线旧令牌在期满前依然有效。所以过期时间不能盲目的长也不能太短到影响体验。我的经验是分场景设置普通登录用 2 小时到 4 小时如果产品允许用户勾选记住我可以签 7 天的长效令牌涉及支付、转账等敏感操作用 15 分钟以内的短令牌甚至在关键操作时强制要求重新输入密码。有人可能会问令牌太短用户用着用着就被弹下线怎么办这就引入了刷新机制。4.2 Refresh Token方案实战Refresh Token刷新令牌的经典做法是登录时发两个令牌一个 Access Token短期比如 2 小时用于业务接口的认证一个 Refresh Token长期比如 14 天或 30 天用于换取新的 Access Token。Refresh Token 一般都带一个随机字符串服务端会存一份用于校验和失效控制。这里放一个简化版的续签接口// modules/auth/auth.controller.js const { v4: uuidv4 } require(uuid); const { signToken, verifyToken } require(../../utils/jwt); const { saveRefreshToken, revokeRefreshToken } require(./refreshTokenStore); async function refreshToken(req, res) { const { refreshToken } req.body; if (!refreshToken) { return res.status(400).json({ code: 400, message: 缺少刷新令牌 }); } // 1. 校验 refresh token 是否有效 const saved await getRefreshToken(refreshToken); if (!saved || saved.revoked) { return res.status(401).json({ code: 401, message: 刷新令牌无效 }); } // 2. 签发新的 access token 和 refresh token const newAccessToken signToken({ id: saved.userId, username: saved.username, role: saved.role }); const newRefreshToken uuidv4(); // 3. 吊销旧 refresh token保存新的 await revokeRefreshToken(refreshToken); await saveRefreshToken(newRefreshToken, saved.userId, saved.username, saved.role); res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken, expiresIn: 7200, }); }注意这里的关键步骤每次刷新都无条件吊销旧 Refresh Token。这样做的原因是如果攻击者窃取了一个长期有效的 Refresh Token他只能在一定时间内使用而合法用户下次刷新时会发现旧令牌已失效就说明令牌可能泄露了安全性会好很多。4.3 续签的常见误区第一个误区是让 Access Token 自动续期。有人为了省事在校验中间件里发现令牌快过期了就自动签发一个新 Token 返回。这样做的问题在于每个请求都会潜在地生成新令牌日志和各种地方全都会出现割裂的身份信息而且并发请求时还会产生多个新令牌互相覆盖的问题。第二个误区是存 Refresh Token 时不做绑定。Refresh Token 随机生成后一定要和服务端存储的用户 ID 绑定刷新时校验该令牌确实属于这个用户。我看到过一些实现Refresh Token 里只存了随机字符串刷新时不去查归属结果任何一个有效 Refresh Token 都能给任意用户签发 Access Token这就非常危险了。第三个误区是忽略了并发刷新。用户在 Web 端开着两个标签页Access Token 过期后两个页面同时发起刷新请求如果处理不当第一个请求吊销旧 Refresh Token 后第二个请求拿着已吊销的令牌就失败了用户会被莫名其妙踢下线。解决方案是刷新接口做幂等处理或者在吊销旧令牌前先做一次并发控制这里不展开细说但面试和实战都值得琢磨一下。5. 常见问题与排查实录5.1 请求老是401从哪排查401 是 JWT 体系里最常报的错误但背后原因五花八门。我列个排查顺序按这个来基本都能定位先看请求头确认 Authorization 字段是否完整格式是否为Bearer token千万别用 tool 测试时把 token 直接放到了别的地方。再看令牌本身把 token 粘贴到 jwt.io 上解析看签名是否提示无效确认是否被中间件或代理改了。再看时间exp 是否已经过了iat 是否比当前时间还晚这种通常出现在服务器时间不同步的环境里。最后看密钥服务端重启后环境变量有没有变多环境部署测试、预发、生产是否各自维护了独立密钥。有一次我调试了很久发现 Nginx 配置里对 header 大小做了限制Authorization 里的 JWT 因为太长直接被 Nginx 丢弃了前端拿到的 401 压根不是后端返回的。这种间接问题是 JWT 排查看起来费劲的原因定位时可以先看后端日志有没有收到请求。5.2 签名验证失败的特殊情况签名验证失败最常见的三个原因密钥不一致、算法不匹配、令牌在传输中被截断。算法不匹配的场景比较隐蔽。比如签发时用的 HS256但有人改了 Header 里 alg 为 none或者在代码里把校验和签发用的密钥配置搞混了。库的接口本身没有防御这种低级错误所以校验时一定要在 verify 里显式固定算法jwt.verify(token, JWT_SECRET, { algorithms: [HS256], issuer: your-app-name, });加了这个algorithms参数之后攻击者把 alg 改成 none 或者 RS256 都不能通过校验这是我强烈建议所有项目都加的一行。5.3 服务器时间不一致导致过期判断错误JWT 的 exp 和 iat 都是绝对时间戳。如果应用服务器和签发服务器不在一个时区或者 NTP 同步出了问题就会出现刚签发的令牌居然显示已过期或者过期了后端却不认这种诡异情况。排查方法很简单在服务器上执行date看系统时间然后对比签发和校验用的时间戳。生产环境一定要开 NTP 时间同步这是很多 Java 和 Node 项目容易忽略的问题。我自己的经验是在测试环境曾经遇见过两个服务之间的时间差了 5 分钟用户登录成功转头就 401排查了很久才发现是环境的锅代码本身没问题。6. 安全加固那些容易踩的坑6.1 密钥管理与泄露应对JWT 的安全根基全在密钥。HS256 的密钥一旦泄露等于攻击者有了签发任意令牌的能力。我建议从这几个维度做防护密钥长度至少 32 字节随机生成不要用英文单词、不要用 uuiduuid 的生成方式有规律可循。不同环境使用不同密钥测试环境的密钥泄露不至于影响到生产。密钥只放环境变量或密钥管理服务如 AWS KMS、Vault严禁写进代码和配置仓库。定期轮换密钥。轮换时需要一个技巧不要立刻换掉旧密钥而是保留一段时间的验签旧密钥 签发新密钥的双密钥窗口让旧令牌能平滑过渡到新令牌。密钥泄露后的应急预案也要提前想清楚第一时间换密钥同时提供一个全局吊销入口把该用户的所有 Refresh Token 全部拉黑迫使其重新登录。6.2 典型JWT漏洞与修复我梳理一下 JWT 生态里出现过的高危漏洞以及对应的修复方式algnone 攻击攻击者把 Header 里的算法改成 none再伪造 payload某些老版本库会直接跳过签名校验。修复就是前面说的显式指定 algorithms。密钥混淆攻击签发用 RS256 时攻击者拿到公钥后如果用 HS256 并以公钥为密钥来签名某些校验逻辑会误认为公钥就是对称密钥从而验证通过。修复同样是指定允许的算法白名单。Token 重放攻击攻击者截获一个合法令牌后在有效期内反复使用。单靠 JWT 本身无法防御需要在敏感场景引入请求绑定比如绑定 IP 或设备指纹或者短时令牌。敏感信息泄露Payload 里不放敏感数据传输全程走 HTTPS避免中间人抓包直接看到令牌。无状态遗漏JWT 无法主动失效所以封号、改密码、踢人下线都要配合 Refresh Token 吊销机制来做。6.3 JWT与Session到底怎么选这是每个做技术选型的人都绕不开的问题。我给一个粗暴但实用的建议你的服务端要不要主动管理登录态如果答案是要比如需要随时封禁用户、需要统计在线人数、需要做操作审计那 Session 或者自研的 Opaque Token 可能更合适。如果答案是不要只想把认证成本降到最低、轻松支持多端和分布式那 JWT 是很自然的选择。还有一种常见的混合方案不把 JWT 当唯一的认证凭据而是用一个随机生成的 Opaque Token 做会话 IDJWT 只作为里面的身份声明。这种方式既保留了服务端吊销能力又能享受 JWT 的轻量校验优点适合大型中台项目。我个人在实际操作中的体会是JWT 最爽的地方是上线即分布式最头疼的地方是想踢一个人下线得绕好几道弯。如果你正在做的是中小规模的项目先把基础认证和刷新机制做对就足够撑很久了等用户量上来、安全诉求多了再引入更重的方案也不迟。最后再分享一个小技巧把所有认证相关的错误码统一登记成一张表比如 1001 代表令牌缺失、1002 代表令牌过期、1003 代表签名无效前端拿到错误码后做统一拦截处理。这个习惯能让你调接口时省下大量的沟通成本前端也不用去猜后端的 message 文案。JWT 这块东西原理不复杂但细节确实很多照着上面的路子一步一步来你的 API 在认证这条线上就不会出大问题。