ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

JWT只认证不授权?后端校验模型与越权漏洞防治理性解析

JWT只认证不授权?后端校验模型与越权漏洞防治理性解析

JWT已经成了现在Web项目里最通用的登录态方案,但我要先说句得罪人的话:大多数人把JWT用成了“一个不会过期的通行证”,却忘了后端还有一道叫“授权”的闸门。

上周帮一个朋友团队做安全评审,他们系统里所有权限判断都写在了前端。按钮用v-if控制,菜单按角色渲染,后端拿到JWT解出来是谁就直接放行。我看着兜了一大圈的“防越权”代码,忍不住问了一句:如果攻击者不去改前端,而是直接拿接口请求工具改一个ID参数,你后端拿什么挡?会议室安静了大概十秒钟。

这篇文章不讲晦涩的理论,就围绕一个核心观点展开:**JWT只能证明“你确实是这个人”,但证明不了“你有权看这条数据、做这个操作”。**后端校验才是防越权的命门。我会把JWT的真实边界、越权发生的几条典型路径、一套可落地的后端校验模型,以及我在真实项目里踩过的三个坑,都摊开来说清楚。

1. 一段“合法”的JWT,到底给后端证明了什么

1.1 三段式结构的核心:签名校验的边界

JWT由三段组成:Header、Payload、Signature。大多数人对前两段看得比较多,因为Base64解码出来就能看到alg、exp、sub、role这些字段。第三段签名反而容易被忽略,但它恰恰是整个令牌信任链的基石。

签名本质上是服务端用私钥或密钥,对Header.Payload做了一次哈希运算后的结果。后端在收到请求时,第一步就是用自己手里的密钥重算一遍签名,比对是否一致。这个过程能验证两件事:一是令牌确实由我的服务端签发,没有被中间人篡改过;二是令牌里的内容在签发之后没有被改动过。

请务必记住一个分界:**签名校验只在验证“来源”和“完整性”,它从来不对Payload里的业务字段做任何“合理性”判断。**哪怕Payload里写着"role": "admin",只要这个字段是签发时写进去的,签名就一定校验通过。后端如果不额外检查当前用户是否真的拥有admin角色,那就等于把门禁卡直接交给了一个自称是管理员的人。

1.2 认证和授权的分工:令牌不等于通行证

做一个简单的概念切分,很多越权漏洞其实是把这两个词混为一谈:

  • 认证(Authentication):确认“你是谁”。
  • 授权(Authorization):确认“你能做什么、能看什么”。

JWT负责的是认证。后端验签通过,能确定请求来自一个已登录用户,知道自己是谁。但“他是谁”和“他能访问哪些资源”是两码事。比如一个普通用户A,他的JWT完全合法,签名也正确,但你后端用这个合法令牌去查用户B的订单记录,这个操作本身在业务上就是不成立的。

我常说一句话:**JWT是身份证,不是工作证。**身份证能证明你是这个人,但你能不能进某个办公室、能不能取某份档案,还需要门禁和工作证系统再做一次检查。后端校验做的就是“门禁系统”那一层,而不是在验完身份证以后就放行。

1.3 前端拿到JWT只代表“凭证已发放”,业务访问还需二次确认

有不少前端开发者会陷入一个误区:既然登录成功后拿到了JWT,里面还带着角色信息,那前端按角色渲染菜单、隐藏按钮,就算权限控制做到位了。这个想法放在单机时代勉强说得通,放在Web架构里是完全不够的。

原因很简单:**前端代码运行在用户自己的浏览器里,用户随时可以打开开发者工具修改它。**按钮隐藏了,不代表接口调用被隐藏了。任何人只要打开调试面板,复制一条请求,改掉里面的ID,重新发送,后端就收到了一个“看起来合法”的请求。如果后端只校验了JWT签名,没有校验对象归属,越权就发生了。

所以,前端控制永远只能改善体验,不能作为安全边界。安全边界必须由后端在每一次业务请求里重新确认。

2. 越权攻击的真实路径:都是从“信任令牌里某个字段”开始的

2.1 水平越权:遍历ID和篡改组织ID

水平越权,学名“IDOR”,通俗点说就是:**一个普通用户访问到了另一个普通用户的私有数据。**它的经典触发点有两个。

第一个是直接遍历对象ID。很多数据表的主键是自增的order_id,比如订单号1、2、3。后端接口写法如果是GET /api/order/{order_id},而处理逻辑里只判断了“这个JWT有没有效”,没判断“这个订单属不属于当前用户”,那么攻击者把order_id从1遍历到10000,就能把所有订单拖走。

第二个是篡改JWT里表示“归属关系”的字段。一些设计不好的系统会把tenant_id、org_id、user_id直接写进JWT的Payload里,后端接口再做查询时,直接用这个org_id去过滤数据。问题在哪?如果后端没有校验“当前用户在JWT里声明自己是哪个组织的,数据库里真是这样吗”,攻击者自己解析JWT,改掉组织ID,再拿着这个新JWT请求一把,就能看到别的组织的数据。

2.2 垂直越权:伪造角色和重放过期令牌

垂直越权是指普通用户拿到了本不该有的高权限操作能力。最典型的就是JWT里塞一个"role": "user",而上层管理接口的判断逻辑是“只要令牌有效就给过”。攻击者把role改成"admin"去请求管理接口,如果后端没有重新查数据库里的真实角色,就会直接放行。

有些系统知道修改签名需要密钥,所以改字段这条路走不通,于是攻击者换了一种思路:**令牌重放。**把之前某个管理员通过合法途径签发的JWT复制下来,在后面很长一段时间里反复使用。如果后端只校验签名和过期时间,不去关心“这个用户当前是否已被禁用、角色是否已被剥夺”,即使他早被踢出管理团队,旧令牌依然能用。

我在安全群里见过很多翻车案例,都是因为把权限数据写死在JWT里,然后天真地以为“签名够安全,里面的角色就可信”。可信任的下限,永远不应该是用户在令牌里自报的角色,而应该是后端服务在业务发生时查出来的真实角色。

2.3 典型案例复盘:前端隐藏了按钮,后端没查

我把一个常见攻击过程完整拆一遍,你就知道前端隐藏按钮有多脆弱。

假设一个内容管理后台,普通作者只能编辑自己的文章,管理员可以编辑所有人的文章。前端做了两件事:第一,根据JWT里的role字段判断是否渲染“编辑他人文章”按钮;第二,点击编辑时携带当前文章ID和JWT,发送PUT /api/article/{id}请求。

攻击者是谁?就是一个普通作者,他登录后拿到自己的JWT。他不需要前端按钮,直接打开抓包工具,构造一个请求,把文章ID改成别人的,把自己的JWT照样附上。后端代码这么写,漏洞就成立了:

@app.route("/api/article/<int:article_id>", methods=["PUT"]) def update_article(article_id): user = get_user_from_jwt(request) # 只做了验签,能识别用户身份 article = db.find_article_by_id(article_id) # 拿到文章 # 问题:没有校验 article.author_id == user.id,也没校验是否管理员 save_article(article) return article.to_dict()

这段代码里,JWT验签是成功的,user的身份是真实的,但后端直接“拿到ID就保存”,完全没有归属校验。攻击者只需要改article_id,就能改别人的文章。整个攻击过程和前端按钮一毛钱关系都没有。

3. 把后端校验建全:从过滤器到业务对象的全链路权限模型

3.1 第一层防线:签名、有效期、签发方、吊销状态

后端校验不是只在业务代码里写一个“if current_user.id == owner_id”就完事的,它应该是一个层层递进的结构。第一层是令牌本身的合法性校验,我通常会在统一的过滤器或中间件里做,避免每个接口重复造轮子。

这套校验至少包含四件事:

  1. 验签:确认JWT由服务端签发,未被篡改。
  2. 有效期:检查exp,过期直接拒绝。
  3. 签发方:检查iss字段,防止其他服务签发的Token冒用。
  4. 吊销状态:在需要强管控的场景,还要查一下“Token黑名单”或系统里的会话版本号,防止注销后的令牌继续使用。

第一层最大的作用是拦截“非法请求”。它解决的是“你拿的是不是假证”的问题。但要注意,这一层通过之后,请求只是被标记为“来自一个已认证用户”,并不代表它可以访问任何资源。

3.2 第二层防线:API资源级别的授权校验

第二层是“接口级权限”。这个接口是给角色A用的,还是给所有登录用户用的,需要在这里做一次粗粒度校验。大多数项目里我会用“权限码”而不是“角色名”来控制接口。角色和权限的关系可以随时调整,具体某个接口该不该放行,给一套权限码管理起来更灵活。

用一个简单的伪代码来表达这一层的思路:

# 统一鉴权过滤器(伪代码,已简化细节) def authorize_request(request, required_permission: str): user = extract_current_user(request) if user is None: raise Unauthorized("用户未登录或令牌无效") # 实时查询数据库中的角色和权限,而不是信任JWT里的role permissions = permission_service.get_permissions_by_user_id(user.id) if required_permission not in permissions: raise Forbidden("当前用户没有执行该操作的权限") return user

这样做的好处是,即使JWT里的角色是“用户A之前的老角色”,后端也会拿最新权限数据来判断。权限被回收后,下一次请求就会被立刻拒绝,不需要等令牌过期。

3.3 第三层防线:数据行级别的“对象属主”校验

这一层最容易被忽略,但它才是防水平越权的关键。一个接口级权限只能保证“拥有article:edit权限的人可以调用编辑接口”,不能保证“这个人编辑的文章是他自己的”。如果系统里编辑权限人人都有,但数据互相隔离,那就必须在数据访问层再做一道“行级过滤”。

常见的做法有三种:

  • 显式归属校验:先查出资源,再比对resource.owner_id与current_user.id,不一致就拒绝。
  • 查询参数注入:在数据查询条件里强制拼接“归属条件”,让数据库层面就查不到别人的数据。
  • 数据权限解析器:封装一个统一的数据权限组件,在ORM执行前自动加上“tenant_id/user_id”等条件。

以订单查询为例,显式校验长这样:

@app.route("/api/order/<order_id>", methods=["GET"]) def get_order(order_id): user = extract_current_user(request) # 经过第一层和第二层 order = order_service.get_by_id(order_id) # 关键:对象属主校验 if order is None: raise NotFound("订单不存在") # 不要泄露订单是否存在 if order.owner_id != user.id and not has_permission(user.id, "order:view_all"): raise Forbidden("无权查看该订单") return order.to_dict()

这里还有一个细节值得单独拎出来讲:**资源不存在和资源不可见,最好返回同样的错误。**如果接口对“不存在”和“无权限”返回不同提示,攻击者可以通过状态码和响应体判断某个ID是否真实存在,这会为后续撞库攻击提供线索。

3.4 对应的最小Claim设计和代码骨架

经常有人问我,到底哪些字段应该放进JWT?我的原则是:只放“身份标识”和“会话属性”,不放“业务权限”。

应放字段:

字段作用
sub用户唯一标识,后端用它去查用户真实信息
iss签发方,通常指向统一认证服务
exp/iat过期时间和签发时间
jti令牌唯一ID,便于吊销和审计

不建议放的字段:

字段为什么不该放
role角色会变,令牌里的角色永远滞后
permissions接口权限应当实时查库
tenant_id组织归属关系应在数据层校验,不应信任令牌里的自报值
user_name方便审计可以放,但不能作为安全判断依据

代码骨架层面,我会把所有校验逻辑封装成三个可复用的部分:

  • AuthenticationFilter:处理验签、过期、签发方。
  • AuthorizationHandler:处理权限码校验,原子性很强,一个接口一个权限码。
  • DataOwnershipGuard:处理对象属主校验,一般封装成装饰器或者工具方法。

三部分分工明确,业务代码里只需要在被保护的接口上声明“需要什么权限”,并在操作数据前调用归属校验即可。不会把安全逻辑散落在各处,也不会出现“这个接口忘写if导致越权”的低级失误。

4. 我在实际项目中踩过的三个坑:重构后彻底根治

4.1 坑一:JWT里的角色没变,后台改了权限,旧令牌依然能访问

某管理后台发生过这么一件事:运营人员在后台把一个用户的角色从“管理员”降为“普通用户”,但那个用户已经登录很久没有退出,手里的JWT还带着"role": "admin"。因为后端所有接口都只检查JWT里的role字段,导致这个用户在权限被降掉之后,依然能用旧令牌调用管理接口,持续了将近一周才被发现。

事后复盘,根因就一条:**把角色权限当成了不可变数据,写死在了令牌里。**修复起来也不复杂,把鉴权逻辑从“读Token里的role”改成“每次请求都去权限服务实时查角色”,同时在关键权限发生变更时,让Token强制失效,也就是引入会话版本号机制。每次用户权限变化,版本号加一,JWT里携带旧版本号的一律拒绝。

4.2 坑二:订单归属放在JWT payload里,一换ID就能查别人信息

第二个坑更有代表性。当时某业务系统为了“减少查询”,直接在登录时把user_id和org_id写进了JWT。后端查询订单列表时,没有去数据库比对当前用户属于哪个组织,而是直接用JWT里的org_id作为过滤条件。

问题在于,JWT的Payload部分只是Base64编码,任何人解码后都能修改字段,虽然改了之后签名会失效,但如果后端从没校验“JWT里的org_id是否与数据库记录一致”,攻击者完全可以用一个自己签发的、使用泄露密钥的令牌来伪造。更简单的攻击方式是:攻击者只要拿到别人泄露的Token,就能以别人的组织身份查询数据,因为后端天然信任Token里的org_id。

修复方案是把归属关系放回数据层:查询订单时,先根据当前用户的sub查出他在数据库里的真实org_id,再带着这个ID去过滤订单。JWT里即使有org_id,也只当缓存提示,绝不作为安全条件。

4.3 坑三:全局线程变量装用户信息,异步线程串号

这个坑和JWT本身关系不大,但因为发生在鉴权链路里,非常值得提防。当时项目为了方便,在过滤器里把解析出的用户信息放进了ThreadLocal,后面的业务代码直接从中取用户。看起来没什么问题,直到引入异步任务和线程池之后,出现了“用户A创建了订单,落库时却记录成用户B”的诡异问题。

排查到最后发现,线程池复用了线程,而ThreadLocal里的用户信息在任务结束后没有被清理,导致下一个任务拿到的还是上一个任务的用户。修复方式很简单:在过滤器里用try-finally保证线程变量用后即清,异步任务则显式传递一份用户上下文,不能依赖线程继承。这个坑提醒我们,鉴权上下文的管理同样要严谨,否则就算授权逻辑全对,最终落到数据层的数据归属也会出错。

4.4 根治方案:权限查询实时化,归属校验下沉到数据访问层

前面三个坑最终都指向同一个原则:后端永远要具备“重新确认”的能力。

  • 令牌里的角色,默认不相信,每次请求实时查。
  • 令牌里的组织关系,默认不采用,数据访问时以数据库记录为准。
  • 线程里的用户上下文,用后即清,绝不复用。

在架构上,我建议把权限过滤、数据归属校验做成公共组件,而不是每个业务模块自己写if判断。公共组件的好处是,新接口上线时只需要在路由上声明权限码,再调用归属校验工具,就不会忘了做安全控制。权限查询实时化看起来牺牲了一点点性能,但换来的是权限变更即刻生效,对后台管理系统来说性价比非常高。

5. 旧系统不重构也能加固:四步把越权漏洞堵上

5.1 第一步:梳理全部接口的“资源属主”矩阵

旧系统往往代码庞大,不可能一次性把每个接口都重写。第一步先别急着改代码,先做一次接口盘点。我习惯用一个表格来梳理:

接口路径操作类型资源类型资源属主关联字段当前是否校验属主
GET /api/order/{id}查询订单order.owner_id = user.id否
PUT /api/article/{id}修改文章article.author_id = user.id是
DELETE /api/user/{id}删除用户仅管理员否
POST /api/project创建项目自动绑定创建人是

这个矩阵的价值在于,把“哪些接口存在越权风险”变得一目了然。梳理完以后,按风险等级排序,优先修复那些涉及敏感数据、资金、隐私的接口。

5.2 第二步:授权入口收敛:统一过滤器加异常兜底

旧系统容易出的另一个问题,是各个接口各写各的鉴权,有的用拦截器、有的在控制器里判断、有的干脆忘写。加固的核心是“收敛入口”。

在系统入口处加一个全局过滤器,负责把JWT里的sub解析出来,放到统一的请求上下文里。同时做一个权限缓存,配置好“接口路径→所需权限码”的映射表。这样即使业务代码原有权限判断写得稀烂,入口处的统一校验也能兜住一部分垂直越权。

遗留系统改造时,建议采用“安全默认值”策略:凡是新接口,默认要求登录且校验权限码;老接口在梳理完成后逐个补上授权声明,没有补之前,至少也要有登录校验,不能裸奔。

5.3 第三步:敏感操作二次鉴权与令牌绑定

对于删除、转账、改密、导出等敏感操作,单靠JWT本身还不够。我会在业务层再加两层保险:

  • 动态令牌复核:用户执行敏感操作时,要求他提供短信验证码或扫码确认。
  • 会话绑定:把JWT与登录时的设备指纹、IP、UA绑定,当检测到这些因素变化时,强制重新认证。

这里不是要给每个页面都弹验证码,而是说关键的写操作不能只看JWT签名就对执行人的身份确信不疑。很多后台系统被撞库后,攻击者直接拿着受害者Token就能删库,如果加一道二次鉴权,哪怕Token泄露了,攻击者也完不成高危操作。

5.4 第四步:权限审计日志与监控告警

防越权不可能百分之百靠代码挡,还需要依赖日志和监控做最后一层兜底。我在排查线上越权问题时,最痛苦的就是日志里看不到“谁在什么时候访问了什么资源”。所以,后端校验必须把关键访问记录下来,至少包括:

  • 用户ID
  • 访问时间
  • 请求路径
  • 资源ID
  • 校验结果(放行或拒绝)
  • 拒绝原因(无权限、资源不存在、Token过期)

有了日志,还可以接一条告警规则。当同一个用户高频访问不同资源ID、或同一ID被大量不同用户访问时,触发告警。这两个特征对应水平越权的两种攻击模式:遍历攻击和目标锁定。

这里有一个现实经验:**绝大多数的越权探测行为,在日志里都会表现为“404异常”或“403异常”的突增。**如果一个接口昨天还在正常返回200,今天突然出现大量403,大概率不是配置写错,而是有人在尝试越权。别把这个信号当噪声忽略掉。

6. 写在最后:别让JWT背锅,后端校验才是“收银员”

用了这么多年JWT,我最大的体会是:**JWT是工具,不是免死金牌。**它天生适合做无状态认证,天生适合做分布式会话传递,但它从来不是一个能替你完成授权的方案。最能防越权的设计,反而是传统得不能再传统的“后端每次都要怀疑一下”的思路——怀疑Token里的角色过期了,怀疑资源的属主不是当前用户,怀疑请求对象不可见。

平时选型时我最喜欢打一个比方:JWT就像你去商场出示的VIP卡,能证明你进过这家商场,能证明你上次消费时确实有VIP身份。但你走进每一家店铺,店员依然得查一下你的会员等级是不是还能用、这张券能不能在这个柜台抵扣。后端校验就是那个“查一下”的动作,省不得,更不能完全交给“卡本身”。

如果你正在接手一个旧系统,先别急着引入复杂的权限框架,把最基础的“对象属主校验”补齐,比换一百个鉴权框架都管用。我自己经历过从“前端控权”到“后端兜底”的完整重构,最大的感受就是:改动虽然琐碎,但安全水位会明显上一个台阶。希望这篇分享能帮你少走一段弯路。

RELATED READING

延伸阅读

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