
做后端开发这几年我踩过最深的坑就是 API 接口裸奔。早期项目用 Session-Cookie 那套前后端同源部署还凑合等到了前后端分离、小程序、App 多端接入的时候Session 的无力感直接拉满。后来换到 JWTJSON Web Token做用户认证与授权才算是真正把 API 保护这块理顺了。这套方案现在已经是行业标配不管是自研项目还是公司内部服务几乎绕不开。今天这篇就把我实际落地 JWT 保护 API 的完整思路、代码细节、以及从网上和各种项目里踩出来的坑一次性整理出来。适合刚接触认证授权的入门读者也适合已经在用 JWT 但想排查漏洞、优化续签方案的后端同学。1. API 认证的痛点与 JWT 的定位1.1 为什么 Session-Cookie 在 API 场景下越来越力不从心早期单体应用最经典的认证方式是 Session-Cookie。用户登录成功后服务端把用户信息存进 Session 容器同时在响应头里写入一个 Session ID 的 Cookie浏览器每次请求自动带上 Cookie服务端查一下 Session 就知道是谁了。这套机制在小规模同源部署下确实好用但放到 API 场景里就暴露出一堆问题服务端必须维护 Session 存储进程重启后 Session 大概率丢失用户全部被迫下线。多实例部署时要么做 Session 粘滞把同一个用户的请求固定打到一台机器要么引入 Redis 做集中式 Session运维复杂度直线上升。移动端 App、小程序、第三方开放平台这类场景客户端习惯用 Header 方式携带身份凭证而不是依赖浏览器自动带 CookieSession 天生不匹配。CORS 跨域时 Cookie 的携带规则非常敏感处理不好就是凭证带不上去或者各种安全警告。我实际做个一个给第三方商家对接的开放平台对方客户端用改造过的定制 SDK根本没有浏览器环境Cookie 完全用不上。当时如果在每台后端机器维护 Session那整个接口鉴权体系就废了。这时候 JWT 的无状态特性就恰到好处服务端不保存会话用户信息直接编码进 Token 里客户端每次请求在 Authorization 头带上这个 Token服务端验签通过就信任身份。1.2 JWT 的本质与它解决的问题JWT 其实是一种紧凑的、URL 安全的声明传递格式。它把 JSON 格式的用户信息、过期时间、签发方等信息经过签名算法加密处理后拼成一个字符串返回给客户端。这个字符串的结构在下一节详说这里先把它的核心价值讲清楚无状态服务端只需要保存用于验签的密钥或公钥不需要保存任何会话数据天然适合水平扩容。跨域友好客户端拿到 Token 后想放哪里都行。后续每次请求在 HTTP Header 里带过来即可。自包含用户 ID、用户名、角色、权限等声明都编码在 Token 内部服务端不需要回查数据库就能拿到基础信息。多端统一Web、iOS、Android、小程序都能用同一套签发和验签流程不需要为不同端定制不同的会话机制。不过 JWT 不是万能的。用户一旦被拉黑、修改了角色权限已经签发的 Token 在过期前仍然有效这种封不禁的问题是 JWT 天生的短板。所以真正线上项目中通常会用短期 Access Token 长期 Refresh Token的组合拳来解决这部分我会在第 3 章的实操里详细拆解。2. JWT 核心原理拆解2.1 三段式结构与每段到底存什么JWT 的字符串长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c用.分成三段Header、Payload、Signature。第一段 Header 存放签名算法和 Token 类型。通常长这样{ alg: HS256, typ: JWT }第二段 Payload 是核心存放标准注册声明Registered Claims和自定义声明Private Claims。标准声明里真正重要的就这几个sub(subject)令牌主体一般放用户 ID。exp(expiration time)过期时间必须严格校验这是防止 Token 长期有效的关键。iat(issued at)签发时间。nbf(not before)在这之前不可用比如给测试账户预留生效时间。iss(issuer) /aud(audience)签发方和受众多服务场景下用来防止 A 服务签发的 Token 被 B 服务误接受。jti(JWT ID)唯一标识一次性 Token、批量撤销场景会用到。自定义声明就自由了比如role、permission、nickname但千万别塞敏感信息。因为 Payload 只是 Base64Url 编码不是加密任何拿到 Token 的人解 Base64 就能看到明文。我见过有人把手机号、身份证号写进 Payload 的这跟把密码写在便利贴上没什么区别。第三段 Signature 是防篡改的关键由 Header 指定的算法基于前两段生成。具体逻辑下一节讲。2.2 签名算法选型HS256 还是 RS256签名算法这一环是网上教程最糊弄人的地方很多人照抄 HS256 就上线了。我建议所有对内多服务、对外开放的平台直接上 RS256。HS256 是对称签名签发和验签用的是同一个密钥。这种方式实现简单单服务部署没问题但一旦有第二个服务也要验签你就得把密钥共享给那个服务。密钥经手的人越多泄露风险越大。更致命的是Token 只能由持有密钥的服务签发如果你打算开放第三方应用接入比如给合作伙伴的开放平台难道把密钥发给第三方的服务器那客户端的 JS 或 App 里必然暴露密钥别人拿到密钥就能冒充你的服务签发任意身份的 Token整个系统等于裸奔。RS256 采用非对称加密思想私钥放在认证服务里负责签发 Token公钥发给所有需要验签的业务服务。公钥只能验签无法伪造 Token。这样密钥只在认证服务内部流转风险范围大大缩小。我在公司的开放平台项目里就用 RS256业务服务通过配置文件或远程接口定期刷新公钥既安全又省去了密钥分发管理。选算法时还需要注意一个问题alg字段是客户端可传的签名算法混淆攻击就利用了这一点。攻击者把 Token 里的alg改成none或者从 HS256 改成 RS256 的对称密钥变体如果服务端没校验算法白名单就可能验签绕过。所以实现验签逻辑时首先判断 Token 头里的alg是否在允许的算法列表里不是就直接拒绝。2.3 Token 放哪里Header、Cookie 还是 localStorage客户端拿到 JWT 后往哪放这个决定影响后面所有前端代码的写法。主流方案有两种放 Authorization HeaderAuthorization: Bearer token。这是最通用、最推荐的做法。服务端从请求头读取 Token不需要考虑 Cookie 跨域问题也方便在 SDK 里显式控制。前端封装 Axios 拦截器时每次请求前从存储位置把 Token 取出来塞进 Header 即可。放 Cookie配合HttpOnly、Secure、SameSite等属性能预留一些 XSS 攻击风险。但跨域请求时 Cookie 策略比较麻烦而且 Cookie 天然会被浏览器自动带上CSRF 风险需要额外防护。我自己是偏向内存 Header的组合单页应用登录后把 Token 放在内存变量里页面刷新前从后台重新刷新 Token如果项目没有特别严格的 XSS 防护能力就退一步放在 localStorage 配 Authorization Header。折中方案是放 memory 里每次刷新从 refresh token 接口拉新的。不放 localStorage 的原因很简单localStorage 对任何同源 JS 都开放一旦页面被注入恶意脚本Token 直接被人拿走而 JWT 又不像 Session 那样可以立刻从服务端撤销。3. 实操从零搭建一套 JWT 认证授权流程3.1 技术选型与依赖准备这一节我用 Node.js Express 作为示例核心依赖是jsonwebtoken签发和验签与express-jwt中间件。Python 生态对应的是PyJWT Flask/DjangoJava 生态对应jjwt或 Spring Security 的 JWT 支持思路完全一样代码换汤不换药。npm init -y npm install express jsonwebtoken express-jwt正文版本我也要提示一下老版本express-jwt的 API 已经变过几次我自己实测时遇到版本不适配导致中间件报错的情况遇到问题先去看当前版本的文档。我这次用的是 8.x 版本。3.2 登录接口签发 Access Token 与 Refresh Token用户登录成功后我们一般同时签发两个 TokenAccess Token 短期15 分钟Refresh Token 长期7 ~ 30 天。Access Token 用来访问业务接口Refresh Token 用来在 Access Token 过期后换取新 Token。const jwt require(jsonwebtoken); // 生成和校验 Token 都需要的密钥实际生产中从环境变量/密钥服务读取 const ACCESS_SECRET process.env.ACCESS_SECRET || access-secret-demo; const REFRESH_SECRET process.env.REFRESH_SECRET || refresh-secret-demo; function signAccessToken(user) { // 声明里只放用户 ID、角色、必要的基础信息禁止放敏感数据 const payload { sub: user.id.toString(), role: user.role || user, type: access }; return jwt.sign(payload, ACCESS_SECRET, { algorithm: RS256 RS256 ? process.env.PRIVATE_KEY : ACCESS_SECRET, expiresIn: 15m, issuer: your-app }); } function signRefreshToken(user) { const payload { sub: user.id.toString(), type: refresh }; return jwt.sign(payload, process.env.PRIVATE_KEY || REFRESH_SECRET, { algorithm: RS256 RS256 ? RS256 : HS256, expiresIn: 7d, issuer: your-app, jwtid: uuid.v4() // 给 Refresh Token 一个唯一 ID便于做撤销 }); } app.post(/api/auth/login, (req, res) { const { username, password } req.body; const user findUserByUsername(username); if (!user || !bcrypt.compareSync(password, user.passwordHash)) { return res.status(401).json({ message: 用户名或密码错误 }); } const { accessToken, refreshToken } issueTokenPair(user); res.json({ accessToken, refreshToken, expiresIn: 900 }); });这里有两个细节容易踩坑第一个是sub字段。它是字符串类型如果用户 ID 是数字要显式转成字符串很多框架的 Token 校验会自动做断言类型不对会验签失败。第二个是密钥管理。我这里示例用了明文字符串线上绝不能这样写。密钥必须走环境变量、配置中心或 KMS 密钥管理服务并且定期轮换。前一段时间网上有一波 JWT 漏洞相关讨论很多案例就是因为硬编码密钥被拖走攻击者直接拿密钥伪造任意 Token。3.3 认证中间件验签 解析 注入请求上下文有了 Token接下来要用中间件验证每一个受保护接口的请求。核心校验逻辑包含四步校验签名、校验exp、校验iss、校验type。const expressJwt require(express-jwt); // 中间件1校验签名、issuer、exp并把 payload 挂到 req.auth const requireAuth expressJwt({ secret: process.env.PUBLIC_KEY || ACCESS_SECRET, // RS256 用公钥HS256 用同一个密钥 algorithms: [RS256, HS256], issuer: your-app, getToken: (req) { const authHeader req.headers.authorization; if (authHeader authHeader.startsWith(Bearer )) { return authHeader.slice(7); } throw new Error(缺少 Authorization Header); } }); // 中间件2进一步校验 type 必须是 access防止拿 refresh token 调业务接口 function requireAccessToken(req, res, next) { if (req.auth req.auth.type ! access) { return res.status(401).json({ message: Token 类型错误 }); } next(); } app.get(/api/profile, requireAuth, requireAccessToken, (req, res) { // 到这里 req.auth 就是解析出的 payload const user getUserById(req.auth.sub); res.json({ profile: user.publicProfile }); });这里我要强调一个很多人忽略的漏洞Refresh Token 与 Access Token 必须区分用途。很多教程只签发一个 Token长期 Token 和短期 Token 不分结果业务接口都会接受一个 30 天有效期的 Token一旦泄露攻击者能长时间控制账户。我上面的实现里在 Payload 里加了type字段并且在业务接口中间件强制校验该字段为accessRefresh Token 接口会校验type为refresh两个通道彻底隔离。另一个细节是algorithms白名单。express-jwt的配置项里必须明确写明允许的算法不是默认忽略。如果这里的算法列表写得比较宽比如同时允许HS256和RS256验签逻辑又对算法类型处理得不够严谨就可能触发我在 2.2 节说的算法混淆攻击。3.4 授权控制用 role / permission 声明做 RBAC认证只是确认你是谁授权是判断你能干什么。JWT 的 Payload 里已经带了role字段授权中间件直接读取即可无需回查数据库。// 通用的 RBAC 中间件工厂 function requireRole(...roles) { return (req, res, next) { const role req.auth req.auth.role; if (!roles.includes(role)) { return res.status(403).json({ message: 无权限执行此操作 }); } next(); }; } // 只有 admin 能删除用户 app.delete(/api/users/:id, requireAuth, requireAccessToken, requireRole(admin), (req, res) { deleteUser(req.params.id); res.json({ success: true }); }); // 只有 admin 和 operator 能查看审计日志 app.get(/api/audit-logs, requireAuth, requireAccessToken, requireRole(admin, operator), (req, res) { const logs getAuditLogs(); res.json({ logs }); });这段代码看起来简单但涉及一个重要的设计取舍把角色和权限直接写进 JWT是否靠谱我的建议是角色这种变化频率低的内容可以放 JWT但细粒度权限最好还是服务端动态校验。原因在于 JWT 的陈旧性——用户从user升级成admin后旧 Token 里的role依然是user最长可能 15 分钟不生效。对于角色调整这种低频操作这个延迟可以接受但如果你的系统有临时封禁、敏感操作二次授权这类高时效需求就不能依赖 JWT 里的权限字段必须走数据库或 Redis 实时查询。实际项目里我采用的是混合方案基础路由能否访问某个模块用 JWT 里的角色声明做粗粒度控制关键接口转账、删除、修改他人数据在业务代码里动态查一次权限表。这样既省了每次请求都查权限库的开销又保证了敏感操作的高可靠性。3.5 Token 刷新机制续命或者踢人二选一Access Token 一般 15 ~ 30 分钟过期。如果过期就让用户重新登录体验太差。所以需要 Refresh Token 来续期。我印象很深的是网上这一块很多教程只讲发两个 Token但没讲连续刷新时旧 Refresh Token 怎么办。我的做法是每次刷新都必须携带 Refresh Token刷新成功后发新 Token 对同时把旧的 Refresh Token 标记为已消费。如果一个 Refresh Token 被重复使用攻击者用两次基本可以认定泄露了后续所有活跃会话全部注销。const tokenBlacklist new Map(); // 生产环境用 Redisv1.0 先用内存 Map 示意 app.post(/api/auth/refresh, requireRefreshToken, (req, res) { const oldJti req.auth.jti; const user getUserById(req.auth.sub); // 检查黑名单防止 Refresh Token 被重放 if (tokenBlacklist.get(oldJti)) { return res.status(401).json({ message: Refresh Token 已失效请重新登录 }); } // 生成新 Token 对 const accessToken signAccessToken(user); const refreshToken signRefreshToken(user); // 旧 Refresh Token 作废 tokenBlacklist.set(oldJti, { invalidAt: Date.now() }); res.json({ accessToken, refreshToken }); });Refresh Token 的存储也是个关键点。如果存在前端 localStorageXSS 攻破后两个 Token 都丢了攻击者可以长期冒充用户。更稳妥的方案是把 Refresh Token 放在服务端管理的 Cookie 里配合HttpOnly和SameSiteLax让脚本读不到。但要注意同源策略和跨域配置反向代理层需要正确传递 Cookie这是一体两面的事。4. JWT 安全加固与漏洞防范大扫雷4.1 从 JWT 漏洞总结看最容易出事的五个点去翻一下社区里的 JWT 漏洞总结能高频看到下面几个问题我在自己的项目里也几乎全踩过一遍风险点攻击方式规避策略无签名 Token将alg改为none去掉签名段服务端强制校验签名并校验算法白名单签名算法混淆把RS256替换为HS256用泄露的公钥签 Token区分公私钥场景禁止对同一 Token 混用两种校验方案密钥泄露代码硬编码密钥、密钥明文进 Git 库环境变量 / KMS 管理密钥仓库扫描加卡片提醒Payload 明文泄露把手机号、密码等敏感数据塞进 PayloadBase64 解码即得Payload 只放必要非敏感声明敏感数据走服务端查询Refresh Token 重放窃取长期有效的 Refresh Token 反复续签一次一用 Redis 黑名单 / 版本号 设备会话绑定这里特别说一下算法混淆。攻击流程是JWT 头原本是{alg:RS256}服务端用公钥验签攻击者把alg改成HS256然后用泄露的公钥作为 HMAC 密钥重新签一个 Token。如果服务端验签逻辑没有固定算法白名单用 HS256 那个公钥去校验就能通过验证。所以我的中间件配置里必须明确algorithms: [RS256]或[HS256]不要两种并存如果并存也要在业务层区分密钥。4.2 密钥轮换与多环境隔离JWT 的整个安全模型建立在密钥保密性上密钥一旦泄露就只能全员强制刷新。实际运营中我会做三件事防患于未然密钥分级每个环境dev、test、prod使用不同的密钥避免测试环境密钥泄露波及生产。定期轮换每 90 天强制轮换一次签名密钥用双密钥平滑过渡——新旧密钥同时可以验签新签发的 Token 用新密钥老 Token 在到期前依然有效。权限隔离私钥只允许认证服务访问运维操作通过密钥权限控制系统审批避免开发人员随手把私钥拷出环境。轮换公钥时要注意客户端缓存。我之前遇到过这种情况认证服务切了新公钥但业务服务还在用旧公钥缓存验签结果所有 Token 都验证失败。后来给业务服务加了一个定时拉取公钥的机制并在切密钥前先重启或刷新缓存问题就解决了。4.3 Token 撤销JWT 无状态的一面之词JWT 无状态的优点前面吹了一堆但它也有一个对应的问题——服务端无法主动让一个 Token 立即失效。怎么破目前业界还没有银弹但有几种实用的折中方案极短 Access Token Refresh TokenAccess 15 分钟就算泄露攻击者也只有 15 分钟操作窗口。用户改了密码、被踢下线Refresh 被撤销15 分钟后所有旧的 Access 自然作废。Redis 黑名单需要撤销的jti提前放进 Redis设置这个 Token 的剩余有效期作为 TTL。验签时多查一次 Redis命中就拒绝。这个方案用空间换即时性适合高安全业务。Redis 白名单Access Token 必须存在于白名单里才有效撤销时直接删掉对应项。这个比黑名单严格但每次验签都要查状态性能和复杂度都上去了。版本号方案在 Payload 里放一个ver字段用户密码修改、角色变更时递增版本号。验签时对比当前用户的版本号不匹配就拒绝。账号安全相关的主动失效能完全覆盖且不需要黑名单存储。我实际用的比较多的是短 Access Redis 黑名单的组合覆盖了大多数业务场景。如果追求更严的安全控制比如银行类短信二次确认后强制下线所有旧会话则上版本号 在线会话记录的组合。5. 常见问题与排查技巧实录5.1 签名验证失败先分四步定位线上最常见的报错就是 401 invalid signature。遇到后先按下面顺序排查不要上来就猜前端确认 Token 是否被中途截断或拼接丢了一个点。打印一下收到的完整 Token数一数.的数量每段是否 Base64Url 编码规范。确认服务端的密钥和签发端一致。HS256 两端必须用同一个密钥RS256 确认你用公钥验签而不是私钥。确认算法配置正确。服务端algorithms列表必须包含签发端实际使用的算法多配或者漏配都会导致invalid algorithms或直接验签失败。确认时钟是否偏差过大。exp校验依赖系统时间如果服务器和签发端时钟相差几分钟可能出现有效 Token 被判定为过期。用 NTP 同步时间不是可选操作。排查的时候可以临时在服务端加日志打印 Token 解析出来的 Header 和 Payload只限测试环境生产上不要打印真实 Token避免日志泄露凭证。5.2 Token 一直报过期但明明设置的是 7 天这是一个高频问题通常不是真的过期而是把单位写错了或者把有效期当成无限制。expiresIn的语义是相对当前时间起的有效期单位可以是15m、60s、7d。千万别传一个 Redis 风格的7d到jsonwebtoken之外的其他库有些库的默认单位是秒写成expiresIn: 60 * 60 * 24 * 7是对的可写成expiresIn: 604800在某一定要传字符串的版本里也可能被当成毫秒解释。真实项目里还见过有人把exp放到了 Payload 自定义字段里而不是使用标准exp字段验签库根本不认这个字段于是 Token 永远不过期。这个不仅是 bug更是一个安全隐患大家在社区里看 jwt 续签、jwt 刷新相关文章的时候先确认作者用的是哪一套字段语义。5.3 前端 401 后应该如何优雅续期前端请求遇到 401 时不能一股脑跳到登录页。正确流程应该是先判断 401 的原因是不是 Access Token 过期可以通过后端返回的code字段或本地对 Token 的exp字段做预判断。若是 Access Token 过期就带 Refresh Token 去刷新接口换新 Token。刷新成功用新 Token 重放刚才失败的请求。刷新失败或 Refresh Token 也过期才跳转登录页。实现时有一点要特别注意并发请求下多个接口同时 401不能每个都各自去刷新 Token否则会刷出一堆新 Refresh Token旧的全部失效前后端会乱掉。我惯用的做法是在 Axios 拦截器里设置一个正在刷新的单例标志其他请求等待刷新完成后再用新 Token 重放let isRefreshing false; let pendingQueue []; async function refreshTokenFlow() { if (!isRefreshing) { isRefreshing true; try { const res await axios.post(/api/auth/refresh); storage.setAccessToken(res.data.accessToken); isRefreshing false; pendingQueue.forEach(cb cb()); pendingQueue []; return res.data.accessToken; } catch (e) { isRefreshing false; pendingQueue []; logout(); throw e; } } // 正在刷新中返回一个挂起状态的 Promise return new Promise((resolve) { pendingQueue.push(() resolve(storage.getAccessToken())); }); }这个方案的另一个好处是业务代码完全不用感知 Token 刷新逻辑只需要在请求器拦截器里统一处理即可。5.4 授权 401 与 403 的语义别搞混这个说起来丢人但我真的在线上见过把权限不足返回 401 的接口。401 是你没有身份凭证或凭证无效服务端根本不知道你是谁403 是服务端知道你是谁但根据你的身份这个资源不允许你访问。语义搞混前端拦截逻辑就乱了权限不足也弹登录框用户明明登录着却被要求重新登录体验很差。后台接口排序时认证失败返回 401授权失败返回 403两者都带上明确的错误描述信息前端分别处理即可。6. 关于 JWT 落地后续的一些思考到了这一步JWT 的基础认证授权体系其实已经跑通了。很多人问我要不要给 Token 做加密我的结论是先看威胁模型。防偷看用 HTTPS 解决防篡改用签名解决防伪造靠密钥管理解决。Payload 加密只是在玩防别人 Base64 解码看内容的伪安全感真正的重点永远在密钥安全和生命周期管理上。真要做后续扩展我会优先考虑这几个方向把黑名单从本地 Map 换成 Redis并加上过期时间引入动态权限策略把角色升级成细粒度权限点再敏感一点的操作接二次认证比如短信验证码或 TOTP最后给每个用户绑定设备 ID在 Refresh Token 里加 jti 做设备会话管理。JWT 不是银弹但它依然是目前前后端分离、多端接入场景下最实用的 API 认证方案。整套东西并不复杂难的是把每个细节都认真对待。这篇文章里写的每个坑都是我在真实项目里踩过、在排查别人代码时看过的。如果你正准备给现有系统加一套用户认证授权或者正在排查线上 JWT 相关的问题照着这套思路走应该能少走不少弯路。