微服务权限设计:JWT+API网关集中鉴权实战
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
,先想清楚:你的网关,真的准备好当那个唯一的守门人了吗?
更多推荐
所有评论(0)