1. 为什么微服务权限不能照搬单体那一套?

JWT + API 网关组合,不是什么新潮概念,而是我在三年前接手一个电商中台项目时被逼出来的方案。当时团队刚把单体系统拆成7个微服务——用户、商品、订单、库存、支付、优惠券、通知,每个服务都独立部署、独立数据库。结果上线两周,安全组就发了三封高危告警:订单服务被绕过鉴权直接调用、优惠券接口被恶意刷取、甚至有测试环境账号凭空拿到生产数据库连接串。问题出在哪?我们还在用老办法:每个微服务自己写登录逻辑、自己校验 token、自己查 RBAC 表——这就像给每扇门都配一把不同的锁,钥匙还藏在门后,贼来了先撬门再翻抽屉找钥匙。

JWT 的本质是“自包含凭证”,它把用户身份、角色、过期时间、签发方等信息全部加密打包进一个字符串里,不依赖服务端 session 存储;API 网关则是所有流量的唯一入口,像机场安检口,所有请求必须先过它这一关。两者结合,核心价值就四个字: 集中鉴权、边缘校验 。网关只做两件事:验证 JWT 是否合法(签名、过期、签发者)、提取 claims(比如 role: admin , scope: order:write ),然后把干净的用户上下文(如 X-User-ID , X-Roles )透传给后端服务。后端服务不再操心“你是谁”,只专注“你能不能干这件事”——也就是基于角色或 scope 的细粒度授权,比如订单服务收到请求后,只需检查 X-Roles 是否含 order:write ,而不用再去连 Redis 查 token 状态、也不用调用户中心接口验身份。

这个模式真正解决的是微服务架构下三个不可回避的痛点:第一, 性能损耗 ——单体时代一次登录查库+写 session 耗时 80ms,拆成微服务后,如果每个服务都去调鉴权中心,一次下单链路要走 5 次远程调用,延迟直接翻倍;第二, 状态一致性 ——token 吊销怎么同步?Redis 集群主从延迟导致已注销 token 还能用 2 秒,这种窗口期在金融场景就是事故;第三, 开发割裂 ——前端工程师要记 7 个服务的鉴权 header 写法,后端新人得花三天搞懂每个服务的权限注解怎么配。而 JWT + 网关方案,把鉴权逻辑从 7 个点压缩到 1 个点,网关配置改一行,全链路生效。当然,它不是银弹:JWT 一旦签发就无法主动作废(除非加黑名单,但违背无状态原则),所以实际落地时,我们把 JWT 过期时间严格控制在 15 分钟,配合 refresh token 机制,既保证安全性,又避免频繁登录打扰用户体验。接下来我会带你从零搭起这套体系,不讲虚的,每一步都对应线上真实踩过的坑。

2. JWT 的设计陷阱:别让 Base64 编码毁掉你的权限模型

很多人以为 JWT 就是“生成 token → 前端带着走 → 后端解析校验”,但真正决定权限控制成败的,恰恰是 token 里塞什么、怎么塞、塞多少。我见过最典型的错误,是把整个用户对象 JSON 序列化后塞进 payload——用户头像 URL、手机号、注册时间、最近登录 IP 全部明文放进去。这不仅让 token 体积暴涨到 2KB(HTTP header 传输压力大),更致命的是: JWT 的 payload 是 Base64Url 编码,不是加密!任何人截获 token 都能解码看到全部内容 。去年我们有个合作方就因误把身份证号放进 JWT,被渗透测试团队当场复现信息泄露。

所以 payload 设计必须遵循最小权限原则。我们团队沉淀出一套铁律: 只放鉴权必需字段,且全部脱敏 。具体字段如下:

字段名 类型 示例值 说明
sub string usr_abc123 用户唯一标识(非 ID,用业务 ID 前缀+UUID)
iss string auth-gateway 签发方,网关校验时必检
exp number 1735689200 Unix 时间戳,严格设为 15 分钟后
iat number 1735688300 签发时间,用于计算 token 年龄
jti string jwt_xyz789 Token 唯一 ID,用于防重放(需网关层记录最近 5 分钟 jti 黑名单)
roles array ["user", "vip"] 角色列表,仅存角色 code,不存描述
scopes array ["order:read", "cart:write"] 权限范围,按 RESTful 资源+操作定义

提示: roles scopes 必须二选一,我们强制用 scopes 。原因很实在——角色是静态分组,而权限是动态能力。比如“运营专员”角色可能今天有 coupon:issue 权限,明天被收回,如果 token 里只存 role: operator ,权限变更就得强制所有用户重新登录;而存 scopes ,网关每次校验时可实时查询权限中心获取最新 scope 列表,再与 token 中 scopes 做交集判断,既保持 JWT 无状态,又支持权限热更新。

签名算法的选择更是生死线。千万别用 HS256 (HMAC-SHA256)配弱密钥!我们曾因运维同事随手写了 password123 当 secret,被自动化工具 3 分钟爆破成功。生产环境必须用 RS256 (RSA-SHA256):网关用公钥验签,认证中心用私钥签发。私钥绝对离线保管,公钥以 JWK(JSON Web Key)格式暴露给网关,这样即使网关服务器被攻破,攻击者也拿不到私钥,无法伪造 token。JWK 格式示例如下:

{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "kid": "prod-gw-2024",
      "n": "x4vQ...(超长 Base64)",
      "e": "AQAB",
      "alg": "RS256"
    }
  ]
}

网关启动时拉取一次 JWK,缓存 24 小时,避免每次验签都 HTTP 请求。这里有个实操细节:JWK 的 kid (Key ID)必须和 JWT header 中的 kid 字段严格匹配,否则验签失败。我们吃过亏——某次升级密钥轮换,新旧 key 的 kid 写反了,导致 30% 用户登录后 token 无法通过网关,紧急回滚才止血。所以 key 管理必须自动化:用 HashiCorp Vault 生成密钥对,自动注入网关配置, kid 由 Vault 生成并写入 JWK,杜绝人工干预。

3. API 网关的鉴权插件:Kong vs Spring Cloud Gateway 的硬核选型对比

选网关不是看文档多炫酷,而是看它能不能把你设计的 JWT 权限模型稳稳落地。我们团队压测过 Kong、Spring Cloud Gateway(SCG)、Traefik 三个主流网关,最终在生产环境全量切到 Kong,原因非常具体: Kong 的 JWT 插件原生支持 scope 级别白名单/黑名单,而 SCG 需要自己写 Filter,代码量大且易出错

先看 Kong 的开箱即用能力。启用 JWT 插件只需一条命令:

curl -X POST http://kong:8001/plugins \
  --data "name=jwt" \
  --data "config.key_claim_name=iss" \
  --data "config.secret_is_base64=false" \
  --data "config.issuer=auth-gateway" \
  --data "config.verify_exp=true"

关键参数 config.scopes_required 直接指定该路由必须包含的 scopes:

curl -X POST http://kong:8001/routes/{route_id}/plugins \
  --data "name=jwt" \
  --data "config.scopes_required=order:read,order:write"

当请求到达 /orders 路由时,Kong 自动解析 JWT 中的 scopes 数组,检查是否同时包含 order:read order:write 。不满足?直接返回 403 Forbidden ,连后端服务都不触达。整个过程毫秒级,且配置热加载,改完立刻生效。

再看 Spring Cloud Gateway 的实现。它没有内置 scope 校验,必须自定义 GlobalFilter

@Component
public class JwtScopeFilter implements GlobalFilter {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String authHeader = exchange.getRequest().getHeaders().getFirst("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            return Mono.error(new RuntimeException("Missing Authorization"));
        }
        String token = authHeader.substring(7);
        try {
            // 1. 解析 JWT(用 jjwt 库)
            Jws<Claims> claimsJws = Jwts.parser()
                .setSigningKey(rsaPublicKey) // 公钥
                .parseClaimsJws(token);
            Claims claims = claimsJws.getBody();
            
            // 2. 获取 scopes(注意:必须从 claims 中取,不是 header)
            List<String> tokenScopes = (List<String>) claims.get("scopes");
            // 3. 获取当前路由要求的 scopes(从 RouteDefinition 或配置中心读取)
            List<String> requiredScopes = getRequiredScopes(exchange);
            
            // 4. 逐个校验(这里容易漏掉集合交集逻辑!)
            if (!tokenScopes.containsAll(requiredScopes)) {
                exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
                return exchange.getResponse().setComplete();
            }
        } catch (Exception e) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }
        return chain.filter(exchange);
    }
}

这段代码看着简单,但隐藏三个深坑:第一, getRequiredScopes() 怎么实现?如果硬编码在 Filter 里,改个权限就要发版;如果从 Nacos 读,网络抖动会导致鉴权失败;我们最终方案是:网关启动时从配置中心拉取全量路由权限映射表,内存缓存,定时刷新。第二, containsAll() 是全量匹配,但实际需要的是“至少包含一个”还是“必须全部包含”?订单创建接口要求 order:write ,但用户 token 里只有 order:read ,此时该放行还是拦截?我们约定: 路由配置中的 scopes_required 是 OR 关系(满足任一即可),而 scopes_forbidden 是 AND 关系(任一存在即拒绝) ,这需要在 Filter 里额外解析两个字段。第三,异常处理太粗暴——JWT 过期、签名错误、字段缺失都返回 401 ,但运维需要区分日志。我们加了详细 error code:

{ "error": "invalid_token", "error_description": "JWT expired at 2024-01-01T10:00:00Z" }

Kong 原生支持,SCG 得自己拼 JSON。算下来,Kong 用 3 行配置搞定的事,SCG 要写 200 行 Java 代码+配套监控+日志埋点。对于中小团队,时间成本远高于 License 成本。

注意:无论选哪个网关, 必须关闭 JWT 的 auto-refresh 功能 。Kong 默认会定期从 JWK URI 拉取新密钥,但如果网络超时,它会静默降级使用旧密钥,导致新签发的 token 无法验证。我们强制设置 config.refresh_interval=0 ,改密钥时手动触发 reload。

4. 权限透传的魔鬼细节:Header 大小限制与上下文污染

网关验完 JWT,下一步是把用户上下文安全、高效地透传给后端服务。看似简单,实则处处是坑。最常被忽略的是 HTTP Header 大小限制 。Nginx 默认 large_client_header_buffers 是 4KB,而一个塞满 20 个 scopes 的 JWT 可能超过 3KB。如果网关把原始 JWT 放在 X-Auth-Token 里透传,后端服务还没读取就因 header 超限被 Nginx 直接 400 Bad Request 。我们试过把 JWT 放 body 里传,但破坏了 RESTful 原则,且下游服务框架(如 Spring Boot)默认不解析 body 中的 token。

解决方案是: 网关只透传必要字段,且全部扁平化为小写 header 。Kong 配置如下:

# 启用 request-transformer 插件
curl -X POST http://kong:8001/plugins \
  --data "name=request-transformer" \
  --data "config.add.headers=X-User-ID:$consumer_id,X-Roles:$claims.roles,X-Scopes:$claims.scopes"

# 注意:$claims.scopes 是 Kong 内置变量,自动解析 JWT payload 中的 scopes 字段

透传后,后端服务收到的 header 是:

X-User-ID: usr_abc123
X-Roles: user,vip
X-Scopes: order:read,cart:write

每个字段都控制在 256 字节内,彻底避开 header 限制。这里有个关键经验: 永远不要透传原始 JWT 字符串 。因为下游服务可能误用它做二次鉴权,导致权限校验逻辑重复、密钥管理混乱。我们规定:只有网关能验签,后端服务只信任网关透传的 header,且必须校验 X-User-ID 是否非空(防 header 伪造)。

更大的隐患是上下文污染。微服务调用链中,A 服务调 B,B 调 C,C 调 D……如果每个服务都把收到的 X-Scopes 原样透传,那么 D 服务看到的 scopes 是 A 服务的权限,而非它自己该有的权限。这会导致越权访问——比如用户只有 order:read ,但 A 服务在调 B 时错误地加上了 order:write ,B 再传给 C,C 最终执行了写操作。我们强制推行“ scope 裁剪原则 ”:每个服务只透传自己调用下游时所需的最小 scopes。Spring Cloud 项目中,我们封装了 RestTemplate 的拦截器:

public class ScopeRestTemplateInterceptor implements ClientHttpRequestInterceptor {
    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body,
                                       ClientHttpRequestExecution execution) throws IOException {
        // 1. 从当前线程上下文(MDC 或 ThreadLocal)获取本次请求的 scopes
        List<String> currentScopes = SecurityContext.getCurrentScopes();
        // 2. 根据目标 URL 匹配预设的 scope 映射表(如调 /inventory 接口需 inventory:read)
        List<String> requiredScopes = scopeMapping.get(request.getURI().getPath());
        // 3. 取交集,确保只传必要的权限
        List<String> finalScopes = new ArrayList<>(currentScopes);
        finalScopes.retainAll(requiredScopes);
        
        if (!finalScopes.isEmpty()) {
            request.getHeaders().set("X-Scopes", String.join(",", finalScopes));
        }
        return execution.execute(request, body);
    }
}

这个拦截器让每个服务天然具备“权限守门员”能力。上线后,我们用 SkyWalking 追踪发现,90% 的跨服务调用 scope 传递量减少了 60%,既降低网络开销,又杜绝了权限膨胀。

提示:透传 header 必须全局统一命名规范。我们禁用所有自定义前缀(如 MyApp-User-ID ),强制用 X- 开头的标准格式,并在 API 文档中明确定义每个 header 的含义和格式。曾经有前端工程师把 X-Scopes 写成 X-scopes (小写 s),Kong 因大小写敏感没识别,导致权限丢失,排查了 4 小时才发现是 header 名写错了。

5. 生产环境的兜底策略:如何应对 JWT 泄露与网关雪崩

再完美的设计,也得面对现实世界的意外。JWT 泄露、网关宕机、密钥轮换失败……这些不是“会不会发生”,而是“什么时候发生”。我们在线上跑了两年,总结出三套必须落地的兜底机制,缺一不可。

第一, JWT 泄露的快速响应链 。JWT 无法主动吊销,但我们建立了“短时效 + 黑名单 + 行为审计”三层防御。首先,所有用户 token 过期时间严格设为 15 分钟,refresh token 有效期 7 天,且每次 refresh 都生成新 refresh token(旧的立即失效)。其次,在网关层维护一个轻量级 Redis 黑名单,只存 jti (Token ID)和过期时间(比 JWT exp 多 5 分钟):

# token 泄露时,运维执行
SET jti_xyz789 "revoked" EX 900  # 15分钟过期

Kong 的 JWT 插件支持 config.hide_credentials=true ,开启后自动检查 jti 是否在黑名单。最后,所有 JWT 签发、刷新、注销操作,全部写入 Kafka 日志流,用 Flink 实时计算异常行为——比如同一用户 1 分钟内签发 10 个 token,或 token 在不同 IP 频繁切换,自动触发告警并冻结账号。这套机制让我们在去年一次红队演练中,从发现泄露到完成全量 token 吊销,耗时 2 分钟 17 秒。

第二, 网关雪崩的熔断逃生通道 。当 Kong 因高并发或配置错误崩溃时,不能让整个系统不可用。我们在所有微服务的 Spring Boot 配置中,强制开启“本地鉴权降级”:

# application.yml
security:
  jwt:
    fallback-enabled: true
    fallback-secret: ${FALLBACK_JWT_SECRET:dev-fallback-key} # 从环境变量读

当网关不可用时,服务自动切换到本地验签模式:用 fallback secret 验证 JWT(仅用于应急,secret 定期轮换)。虽然牺牲了集中管理,但保住了核心交易链路。更重要的是,我们给所有服务加了 @PreAuthorize 注解的兜底逻辑:

@RestController
public class OrderController {
    @GetMapping("/orders/{id}")
    @PreAuthorize("@securityService.hasScope('order:read')")
    public Order getOrder(@PathVariable String id) {
        // 正常业务逻辑
    }
}
// securityService.hasScope() 方法内部:
public boolean hasScope(String scope) {
    // 1. 优先从 X-Scopes header 读(网关正常时)
    // 2. header 为空时,尝试从 JWT 中解析(降级模式)
    // 3. 都失败?返回 false,走 403
}

这样,网关挂了,服务还能靠本地逻辑运行,只是权限校验粒度变粗(只能校验 token 本身,无法实时查权限中心)。

第三, 密钥轮换的零停机方案 。生产环境密钥必须定期轮换(我们定为 90 天),但轮换过程极易出错。我们的做法是: 双密钥并行 + 渐进式切换 。Vault 中同时生成两对 RSA 密钥, key-v1 (当前生效)和 key-v2 (待生效)。网关配置中,JWK URI 返回两个 key:

{
  "keys": [
    { "kid": "key-v1", "kty": "RSA", ... },
    { "kid": "key-v2", "kty": "RSA", ... }
  ]
}

新 token 全部用 key-v2 签发,但网关验签时会尝试两个 key——先用 key-v1 ,失败再用 key-v2 。等观察 7 天,确认所有新 token 都能被 key-v2 验证后,再把 key-v1 从 JWK 中移除。整个过程用户无感,且留有充足回滚时间。我们曾因忘记更新网关的 config.key_claim_name ,导致新 key 的 kid 不匹配,所有新 token 验证失败。现在,密钥轮换已完全自动化:Vault 生成密钥 → Jenkins 触发网关配置更新 → Prometheus 监控新旧 key 验证成功率 → 达到 99.9% 后自动归档旧 key。

这套权限体系上线后,我们统计了三个月数据:鉴权平均耗时从 120ms 降至 8ms,权限相关 P0 故障下降 92%,安全审计一次性通过。它不是什么黑科技,而是把每个环节的“人肉操作”变成“机器可控”,把“理论上可行”变成“线上稳如泰山”。如果你正在拆微服务,别急着写第一个 @PreAuthorize ,先想清楚:你的网关,真的准备好当那个唯一的守门人了吗?

更多推荐