ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OAuth 2.0+OIDC+JWT全流程实战:从授权码到安全落地

OAuth 2.0+OIDC+JWT全流程实战:从授权码到安全落地 OAuth 2.0、OpenID ConnectOIDC和JWT这三个词在近几年的后端项目里几乎成了默认组合。不少团队把三者串成一条流水线登录时先走OAuth的授权码流程OIDC负责把用户身份信息变成一个ID TokenAccess Token用JWT格式发放给客户端然后再带着它去调用业务接口。单纯看每一步都不复杂可一旦把它们拼到一起很多问题就出来了token里该放哪些字段、refresh token怎么安全保存、SPA里验证码校验和登录流程怎么衔接、JWT常见漏洞怎么防——这些坑我基本都踩过这篇把整个流程和注意事项完整梳理一遍。文章适合刚接触认证授权的后端/前端同学也适合做技术方案选型时对照检查。1. 先把协议边界捋清楚OAuth 2.0管授权OIDC管认证JWT管传递1.1 授权和认证的区别以及单一方案为什么不够用很多人一开始最容易绕晕的是认证和授权这两个概念。认证是回答“你是谁”授权是回答“你能干什么”。OAuth 2.0本质上是授权框架它设计出来的目的不是告诉第三方应用你是谁而是允许第三方应用在得到用户同意后获取某个受保护资源比如用户的微信头像、GitHub仓库的访问权限。你不能把OAuth 2.0直接当登录来用因为它没有定义用户身份怎么传递。OIDC则是在OAuth 2.0基础上加了一层身份层通过ID Token把用户的唯一标识、邮箱等身份信息安全地交给客户端。而JWT是一种编码和签名格式它本身跟认证授权无关只是因为能把claims以紧凑、自包含的方式传递非常适合用来承载Access Token和ID Token。这三者不是竞争关系而是分工。我见过有团队只用一个JWT自签token没有标准协议前端把用户id藏在payload里后端验签通过就信任也见过有团队只做OAuth 2.0流程却没有OIDC层拿到access token之后还得再调用一个用户信息接口才知道当前用户是谁。单独用都能跑通但在多应用、跨站点、需要与第三方系统对接的场景下标准协议能大幅降低沟通和实现成本。JWT解决的是“信息怎么安全地放进去、怎么防止篡改”OAuth 2.0解决的是“授权这个动作怎么标准化”OIDC解决的是“如何把登录结果标准化”。只有组合在一起才形成一套完整的认证授权闭环。1.2 组合后的整体流程从登录到访问受保护资源我把一次完整的请求画成一条流水线。这里以最常用的授权码模式为例用户访问SPA首页前端检测到没有登录态就带着client_id、redirect_uri、response_typecode以及一个随机生成的state参数跳转到授权服务器的登录页。授权服务器先做用户认证通常需要用户名密码、扫码、验证码等认证通过后让用户选择是否授权这个应用最后重定向回前端并携带一个authorization code和state。前端拿到code后在后端通过客户端凭据client_secret或PKCE验证器去换取token这个token响应里包含了access_token、refresh_token以及在scope包含openid时的id_token。之后前端调用业务API时在Authorization头里带上Bearer access_token。资源服务器用JWT公钥验签、校验过期时间、校验scope/audience通过后放行。这一步里有两个经常被忽略的校验点。第一个是state参数前端发起的请求里生成一个随机值并存在本地授权服务器重定向回来时必须原样带回前端要逐字符比对防止登录CSRF——攻击者诱导用户带着你的登录态去完成授权然后把code回收给自己的回调。第二个是授权码本身的有效期必须很短并且只能换一次换完就要在授权服务器侧作废防止中间人拿到code后慢慢兑换。很多实现只用了个随机字符串当code没有在服务端标记“已使用”这就是一个典型漏洞。1.3 常见误区把OAuth 2.0当登录用、把JWT当保险箱先说把OAuth当登录用的误区。有些平台文档里看到OAuth就以为它能直接解决“用户登录”于是把第三方登录框接进来拿到access_token后只在本地存起来下次请求带上后端看到有个JWT就直接信任。Access Token只代表授权服务器允许你访问某个资源它并不可见用户是谁也不表示用户当前session是否真实存在。严格来说获取用户身份要用OIDC的id_token或调用UserInfo端点。所以如果你要的是登录能力请明确response_type里包含openid和id_token并校验nonce而不是自己从access_token里猜用户名。把JWT当保险箱也是个常见错误。JWT的Payload只是Base64Url编码任何人都能解出来看它不是加密存储。敏感字段密码、身份证号、手机号绝对不要放进JWT。JWT的价值在防篡改、防伪造不是保密。需要保密的信息应该放在授权服务器后端的存储里或者通过加密JWE传输。很多公司曝出的token泄露问题本质上就是把不该放的字段放进了payload一旦token落到日志里就成了数据泄漏事故。2. 从授权码到ID Token一次完整请求的细节2.1 授权码模式中的请求细节state、nonce、redirect_uri授权码模式里参数没设计好安全就破了一个角。state我前面提到了它是防CSRF的关键。这里再补充一个同样重要的参数nonce。这个参数是OIDC在OAuth 2.0基础上加上的它的作用是防重放客户端在认证请求里生成一个nonce值OIDC Provider生成的id_token里必须原样包含这个nonce客户端验签之后还要比对nonce确保这个ID Token确实是在响应自己刚才那个请求而不是攻击者回收的老token。因为ID Token是JWT即便没有nonce也能验签通过但没有nonce就可能被重放到另一个上下文。redirect_uri同样需要严格核对。授权服务器必须只允许预先注册的redirect_uri并且在每个授权请求里都校验实际的值不能只校验域名前缀否则攻击者可以把回调地址改成自己控制的地址拿到code。很多老项目没做这个校验或者校验得比较宽比如用字符串包含判断都能被绕过。我通常建议在注册客户端时按全路径保存回调时逐字符精确匹配。一个标准的授权URL长这样https://auth.example.com/oauth2/authorize?response_typecodeclient_idspa-clientredirect_urihttps%3A%2F%2Fapp.example.com%2Fcallbackscopeopenid%20profile%20emailstatexyznonceabc可以看到state和nonce同时存在一个负责绑定浏览器上下文一个负责绑定ID Token的上下文。实际开发中很多脚手架默认帮你生成了state但nonce需要自己在OIDC库的配置里显式打开容易漏掉。2.2 ID Token与Access Token到底分别怎么验证ID Token的验证逻辑在这条链路里最容易出错。拿到id_token后第一步验签名拿到授权服务器的JWKS里的公钥用JWT的alg指定的算法验签第二步验时间exp和iat第三步验audience确保这个token是发给自己的client_id不是别的客户端第四步验nonce防止重放第五步验issuer确保证书来自预期的授权服务器。这五步缺一不可。很多社区里的demo为了省事只验了exp和签名在真实攻击里会带来严重问题。Access Token的验证策略分两种如果在资源服务器和授权服务器之间共享JWKS公钥那么资源服务器可以直接本地验签如果Access Token是OAuth 2.0默认的opaque不透明格式那么资源服务器就得调用授权服务器的introspection端点来实时校验。JWT格式的Access Token确实是自包含的但自包含也意味着一旦签发后在过期前无法立即吊销所以在实现时需要设置合理的短过期时间配合refresh token做续期。2.3 从JWT解析到UserInfo服务端该如何组织claims设计JWT claims时我倾向于把它们分成三类身份类sub、preferred_username、email、授权类scope、role、permission、元数据类iss、aud、iat、exp、jti。OIDC标准里规定了ID Token必须包含iss、sub、aud、iat和exp可选包含auth_time、nonce等。这里要特别注意不要自己发明一堆业务字段堆在claims里JWT是给资源服务器做访问控制用的不是给业务数据库做查询用的。需要复杂业务上下文时资源服务器应该拿sub去查自己的用户系统而不是试图把所有业务信息塞进token。组织claims还有一个细节是scope和role的关系。scope是OAuth层面的权限比如read:orders、write:orders它对应的是“这个token能调用哪些接口”role/perm则是业务层面的角色权限。二者经常被混在一起写但标准做法是Access Token里携带scope资源服务器根据scope来判断业务角色放在ID Token或UserInfo里由业务系统内部处理。混放会让token变大也容易把授权边界搞乱。3. SPA场景下的JWT落地验证码、存储与续签3.1 登录前的验证码校验怎样和JWT发放衔接SPA场景跟我们刚讲的授权码流程有不同。SPA没有后端保管client_secret所以一般用授权码模式加PKCE或者直接走自己的登录接口发JWT。无论是哪种登录前的验证码校验都是防暴力破解的第一道闸门。常见的做法是用户输入账号密码前前端请求一个验证码接口后端生成一张图片或一次滑块挑战并把验证码的唯一id和答案放在服务端缓存里比如Redis返回给前端的是一个captcha_id验证时把captcha_id和用户输入一起提交。这样做的好处是验证码的答案不会暴露在前端且验证码状态可以设置一次性有效、可过期。在JWT发放的链路上我建议把验证码校验放在登录接口内部作为账号密码校验的前置步骤而不是单独暴露一个校验接口否则攻击者可以跳过校验逻辑暴力刷新验证码。验证码本身也要做频率限制同一个IP、同一个账号前缀的多余请求直接拒绝验证码的答案在Redis里用哈希存储过期时间控制在5分钟左右用完即删。这里我踩过的一个坑是验证码接口本身没有限流导致攻击者通过疯狂刷新验证码接口占用Redis内存。后来给验证码接口加了全局限流并限制每个会话同时只能有一个有效验证码。3.2 Token存哪里localStorage与内存之间的取舍这是SPA里争论最多的问题。把Access Token存localStorage最简单但XSS一旦存在攻击者可以直接读走token。把token放内存里可以降低XSS风险但刷新页面就会丢需要靠refresh token放在HttpOnly Cookie里来换新的access token。我现在的推荐方案是Access Token放内存或sessionStoragerefresh token放HttpOnly Secure SameSite cookie。这样XSS读不到refresh tokenCSRF也因为SameSite策略受到限制可以说是目前SPA体系里相对均衡的取舍。如果你们的SPA要走纯cookie方案把JWT放在HttpOnly cookie里那就得注意CSRF防护SameSitestrict或lax 额外校验自定义Header比如前端统一在请求头里带X-Requested-With。最怕的是把token放在localStorage同时又在代码里到处console.log请求信息或者引入第三方脚本这种组合一旦出问题攻击者直接把token拿走。没有绝对安全只有把攻击面变小。3.3 刷新令牌的真实玩法滑动过期与refresh token轮换Access Token的生命周期应该短我一般在生产环境设为15分钟到1小时太长了吊销难、风险高太短了刷新频繁、体验差。对应地refresh token的生命周期可以长一些比如7天到30天但必须强调refresh token是更高等级的凭证它丢了等于账号长期被控。我建议每次用refresh token换取新Access Token时都做轮换也就是上一条refresh token立即作废返回一个新的refresh token。这样即使攻击者偷到了某次请求里的refresh token也只能用一次暴露窗口会大幅缩短。滑动过期指的是只要用户在活跃地使用系统就给他续期比如离过期不足一天时刷新refresh token让会话持续有效如果超过30天没有活跃强制重新登录。这个策略需要结合用户行为做实现起来不复杂但要注意不能在refresh接口无限续期否则一个被盗的refresh token可以一直续到天荒地老。我通常会给同一个refresh token设置最大累计生命周期比如就算一直刷新最多也只能用60天到期必须重新登录。3.4 续签实现中的并发控制与失效处理刷新token时最容易被多线程踩坑。比如前端两个请求几乎同时发现Access Token过期各自拿着同一个refresh_token去刷新后端如果处理不好第一个请求轮换成功后第二个请求再用旧refresh_token就会失败。解决思路有两个前端做刷新请求的队列只让一个请求去刷新其他的等这个完成后用新token重放后端则要做refresh token的原子校验和替换比如用Redis的GETDEL或Lua脚本确保同一时刻只有一个请求能成功轮换。这里还要处理一种常见情况用户刷新页面时内存里的access token丢了但refresh token还在那前端要能自动拿着refresh token去静默换新token。如果refresh token已经失效就要主动跳转登录页而不是在业务接口里反复报401。很多团队就是在这个环节处理不干净导致用户看到一堆401报错弹窗。实现时可以在统一的fetch拦截器里判断401再判断当前是否有刷新请求在飞没有就发起刷新刷新成功重放原请求失败则清空登录态并跳转登录页。4. 我在实际排查中遇到的JWT安全漏洞含防护清单4.1 算法混淆攻击为什么必须校验alg和kidJWT最经典的漏洞就是算法混淆攻击。攻击者截获一个正常签发的JWT把它丢到jwt.io上解出来然后把header里的alg改成none或HS256。为什么这能攻击因为有些JWT库在验签时会信任header里的alg字段。如果资源服务器代码里写的是“根据alg动态选择验签算法”攻击者可以把它从RS256改成HS256然后用授权服务器的RSA公钥作为HMAC的对称密钥来签名。由于公钥是公开可获取的攻击者可以自己签发一个完全合法的token。这个洞在很多老教程里反复出现现实里我也确实遇见过一个内部系统用的就是动态选择算法的库结果被安全测试一打一个准。防护办法很直接服务端在验签时固定一组允许的算法白名单比如只允许RS256/ES256并且禁止none每次验签前还要校验alg是否在白名单内不能信任header里的kid和alg。kid这个字段也有坑如果库支持从kid指定的URL或路径加载密钥并且对kid值没做严格校验攻击者可能传一个“../../etc/passwd”来诱导服务端加载任意文件。所以kid要当作普通字符串处理绝不能直接作为文件路径或URL去拼接。另外验签时需要确保使用的密钥和alg匹配。一个保险做法是用JWKS的kid先找到对应key再校验该key的alg是否与token头部alg一致双保险。4.2 弱密钥爆破、敏感信息泄露、过期放任JWT另一个高频漏洞是弱密钥爆破。如果签名的密钥是一串常见的密码比如secret、123456、changeme攻击者可以本地对一些已知token做字典攻击很容易破解。这个问题的根源是很多脚手架默认给了一个弱密钥开发时没改。我建议所有JWT签名密钥至少有256位随机熵并且用密钥管理服务或环境变量注入别写在仓库里。就算密钥随机了还要防范时间侧信道在验签对比签名时用JWT库提供的常量时间比较方法而不是直接用比较字符串。敏感信息泄漏前面已经说过再补一个实际案例曾有人为一套内部系统调试时把JWT payload里加了个user_id字段然后后端日志默认会打印整个Authorization头结果日志里躺满了含完整token和user_id的记录任何能读日志的人都能伪造请求。我后来强制约束了日志脱敏Authorization头一律只记录前20个字符加“...”payload里也彻底去掉了业务字段只保留sub。这个经验比任何安全框架都实在。还有过期时间放任的问题。有些实现把Access Token设成30天甚至永不过期或者干脆没写exp。这样token一旦泄露就是一个长期后门。我建议过期时间需要分层Access Token短Refresh Token中如果还有API密钥密钥要可轮换。同时对exp的校验不能只靠库还要检查时间戳本身不是被改成非常大的数字防止有人把exp改成9999999999继续用。使用clock skew容忍度时要谨慎别为了容忍服务器时钟偏移把容忍度调到几分钟以上。4.3 令牌吊销与双通道防泄漏的经验JWT的无状态特性带来一个老大难问题token泄露了怎么撤销短过期时间可以缓解但无法立刻收回。我的通用做法是配合Redis做一个黑名单或version版本号每个用户/客户端维护一个token_versionJWT里带一个ver字段资源服务器每次验签时查Redis里的该用户最新version如果JWT里的ver与Redis不一致直接拒绝。这样踢人下线、改密码、封号都能立刻生效。代价是每次请求多一次Redis查询或者用本地缓存放ver牺牲一点实时性。这套方案比维护黑名单更简洁因为ver只需要一个整数。双通道防泄漏指的是不要在请求参数、日志、明文存储等通道里重复暴露token。登录时返回的token只通过前端内存或HttpOnly cookie一条路传递后端不要把它写进数据库表里的可读字段更不要把它作为URL query参数传递。如果必须放在header里确保只用Bearer方案如果遇到网关要注意网关日志默认也会记录header里的Authorization需要做专门脱敏配置。这些都是我在运维审计时反复提醒自己的点。4.4 安全加固的检查清单把上面这些经验整理成一张可以直接对照的检查清单方便你们在评审设计文档或代码时逐项打勾。检查项推荐做法关键原因算法白名单固定只允许RS256/ES256禁用none防算法混淆攻击密钥强度不小于256位随机熵存环境变量/KMS定期轮换防弱密钥爆破payload最小化只放sub/scope/标准claims不放敏感PII防日志泄露过期与签发时间Access Token 15-60分钟Refresh 7-30天带jti控制泄露窗口验证码服务端存储、一次性、限流防暴力破解与内存耗尽refresh token轮换每次刷新作废旧token发新token限制重放重放与CSRFstate/nonce校验SameSite cookie防CSRF/重放日志脱敏隐去Authorization头部防token从日志泄露吊销机制token_version或黑名单实现即时失效别把所有压力都压在JWT身上传输层HTTPS、接入层WAF、全链路审计也一样重要。清单只是底线再往外还要看整体架构。安全设计从来不是一个token格式能解决的而是每一层都要有明确的信任边界和失效策略。5. 一套可参考的代码骨架从发放、验收到刷新5.1 授权服务器端签发Access Token / ID Token的代码思路这部分我用Node.js jsonwebtoken写一个简化的签发示例思路可以迁移到任何语言。签发前先验证用户密码、验证码通过然后生成jti唯一标识设置sub为user_idscope按用户角色生成exp按策略生成ID Token则额外带nonce、auth_time。代码大致是这样仅为演示生产环境请用成熟OIDC库const jwt require(jsonwebtoken); const crypto require(crypto); function issueTokens(user, clientId, nonce) { const accessToken jwt.sign( { sub: user.id, scope: user.scopes.join( ), jti: crypto.randomUUID(), client_id: clientId }, privateKey, // RS256私钥 { algorithm: RS256, issuer: process.env.OIDC_ISSUER, audience: process.env.API_AUDIENCE, expiresIn: 15m } ); const idToken jwt.sign( { sub: user.id, auth_time: Math.floor(Date.now() / 1000), nonce }, privateKey, { algorithm: RS256, issuer: process.env.OIDC_ISSUER, audience: clientId, expiresIn: 5m } ); return { accessToken, idToken }; }注意ID Token的audience是clientIdaccess token的audience是资源服务器标识这个区别必须分开。生产环境还要保存tokens记录到审计并设置refresh token轮换状态。这里建议直接调用OIDC库它会处理很多细节不建议自己全手写。5.2 资源服务器端JWT校验中间件怎么写资源服务器端不需要访问授权服务器只要持有对应公钥JWKS就能本地校验。校验中间件的核心步骤拆成四步先从Authorization头取token用JWKS里的公钥验签检查iss/aud/exp最后查一下token_version是否有效。以下是伪代码思路const jwt require(jsonwebtoken); async function verifyToken(req, res, next) { const authHeader req.headers.authorization || ; const token authHeader.startsWith(Bearer ) ? authHeader.slice(7) : null; if (!token) { return res.status(401).json({ error: missing_token }); } try { const payload jwt.verify(token, publicKey, { algorithms: [RS256], // 白名单 issuer: process.env.OIDC_ISSUER, audience: process.env.API_AUDIENCE }); // 可选查Redis中的token_version做即时吊销 const currentVersion await redis.get(user:version:${payload.sub}); if (currentVersion payload.ver ! undefined payload.ver ! currentVersion) { return res.status(401).json({ error: token_revoked }); } req.auth payload; next(); } catch (err) { return res.status(401).json({ error: invalid_token }); } }这里要提醒jsonwebtoken的verify函数默认会做exp校验但算法白名单一定要显式传algorithms字段否则库可能从header里读取算法。很多人以为自己用的库安全实际默认配置可能不安全。验签通过后尽量把payload挂到req上不要重复解析。5.3 客户端记下踩坑经验时钟偏移、base64url编码、Bearer头最后写几个客户端和服务端对接时容易踩的细节。第一个是时钟偏移。部署在内网的多台服务器之间可能有几秒到几十秒的时间差如果服务端时钟比授权服务器慢JWT的nbf或exp校验可能不通过。此时可以在验签库中设置一个较小的clockTolerance比如5秒但别设太大。第二个是base64url编码JWT的header和payload用的是URL安全的base64和标准base64有区别如果自己解析base64字符串别忘记把-和_替换回和/否则字符串会错位。第三个是Bearer头的大小写和空格很多客户端把Authorization写成了Bearertab或小写bearer虽然部分服务器宽容但按严格标准只认Bearer前缀。我写过一个helper统一处理这个问题能被这个问题坑一顿也算是老开发了。除此以外还有一个小坑一些API网关或反代默认会剥离或改写Authorization头如果发现请求到了后端就没了token检查一下网关配置别把锅甩给后端。还有不要在代码里写死token做demo尤其是放到公共仓库里的demo密钥轮换要跟上。这套OAuth 2.0 OIDC JWT流程组合说简单也简单说复杂真的能用几百页文档铺开。我在好几个项目里实践下来最大的感受是不要自己重复造轮子协议细节和库已经帮你踩过了你真正要做的是把白名单校验、token生命周期、吊销、日志脱敏这些“流程外”的事做好。如果现在再来一次我可能会先画一张表把每个环节的信任边界写清楚再写代码。最后分享一个小调试技巧任何JWT调不通的时候把token复制到jwt.io先看header和payload很多问题一眼就能定位到alg不匹配、audience写错这类初级错误上。希望这篇能让你们少走几步弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进