在微服务架构里,API Gateway 几乎是所有流量的必经之路。它很容易被视为「安全边界」——认证、授权、限流、审计,统统塞到网关上。但这是一个危险的简化。网关能做的事有明确上限:它擅长处理与请求本身相关的信息(签名、claims、路径、IP、速率),而不擅长处理与业务数据相关的信息(这个用户是不是这个文档的所有者、这个订单是否在他可操作的工作流阶段)。把所有授权都放在网关,等同于只有一道锁:一旦越过,整个内网就是敞开的。
这篇文章想讲清楚三件事:Gateway/BFF/Service 三层在认证授权上各自的职责边界、Token 在哪里验证以及怎么验证、服务间身份如何传播(JWT 透传、mTLS、SPIFFE)。最后对 Istio、Kong、APISIX 三类常见方案做横向对比,给出选型建议。
一、边界的职责:Gateway 能做什么,不能做什么
讨论网关的时候,先把「网关」这个词拆开。在实际部署里,它通常是下面几种角色的叠加:
- 反向代理(Nginx/Envoy 层面):TLS 终止、路由、连接池
- API 网关(Kong/APISIX/AWS API Gateway):认证、限流、路由改写、插件化策略
- BFF(Backend for Frontend):为特定前端聚合请求、塑形响应、维持会话
- 服务网格入口(Istio IngressGateway、Linkerd):东西向与南北向流量的统一入口
不同团队对「网关」的理解差异很大,这也是为什么讨论职责边界时常常各说各话。本文里「网关」特指南北向入口的 L7 代理,它看得到 HTTP 头、Body(如果要付代价的话)、路径、方法,但看不到服务内部的业务状态。
1.1 网关擅长的事
网关的优势在于:它是所有流量必经的单点,且只需要处理请求元数据。以下这些事情放在网关做,既集中又高效:
- JWT 签名校验。网关从请求头里拿到 Bearer
Token,向 IdP 的 JWKS
端点拉公钥,验证签名、
exp、nbf、iss、aud。通过则放行,失败直接返回 401。这一层不需要触达任何业务数据。 - 限流(Rate Limit)。按 IP、按 API Key、按用户 ID 限速,防 DoS 与爬虫。限流状态通常存在网关自带的内存 / Redis 中。
- IP 白名单 / 黑名单。合规场景(金融、政务)常见。
- 粗粒度角色检查。比如
/admin/*路径只允许包含role=adminclaim 的请求通过。注意:这种检查只能基于 Token 自带的信息,不能去查数据库。 - 请求日志 / 审计。谁在什么时间请求了哪个路径、用的哪个 Token ID。
- 协议转换。gRPC-Web 转 gRPC、REST 转 GraphQL 之类。
- 熔断、重试、超时。这些与认证授权关系不大,但也是网关的标准能力。
1.2 网关做不了的事
细粒度授权(尤其是对象级授权,object-level authorization)无法在网关完成,因为它需要业务上下文。典型场景:
- BOLA / IDOR(Broken Object Level
Authorization)。用户 A 请求
GET /api/documents/123,Token 完全合法,role=user也通过了网关的粗粒度检查,但文档 123 是用户 B 的私有文档。网关看不到documents表,无法判断。 - 业务上下文检查。「当前订单处于 pending 状态时,只有下单者可以取消」「某个租户的工单只能被同租户的客服回复」——这些规则依赖订单状态、工单归属、用户与租户的关系,都是业务数据。
- 基于属性的复杂决策(ABAC)。「用户所在部门是文档所属项目的成员,且当前时间在工作日 9:00~21:00,且操作来自公司网段」——属性散落在用户中心、项目中心、HR 系统里,网关一个也够不着。
- 写操作的业务前置校验。库存够不够、余额够不够、幂等键是否重复——这些是服务层必须做的事。
把这些放在网关里,要么做不出来,要么做出来就是一个逻辑重复、数据陈旧、故障域巨大的「超级网关」。见过真实案例:某公司把几十个业务规则写进 Kong 的 Lua 插件,最后网关重启一次要 40 秒,任何规则调整都要全公司停机窗口。
1.3 「网关即边界」反模式
这个反模式的典型症状:
- 网关里堆砌大量业务授权逻辑,服务内部没有任何授权代码
- 服务之间互相完全信任,从网关进来的请求带一个
X-User-ID头就当作可信身份 - 内部网络被当成「可信区」,所有东西向流量裸奔
- 一旦某个服务有 SSRF、或内网的某台机器被攻破,整个内网横向移动毫无阻力
它的假设是「网关足够强、内网足够安全」,但云原生环境下这两个假设都不成立。容器会逃逸、Sidecar 会被投毒、管控面会被攻破、开发人员会在联调环境跳过网关直连。零信任架构(Zero Trust)的核心就是反对这种「一次验证、全程信任」的模式。
1.4 三个授权区域
一个可落地的心智模型是把授权分成三个层次(Zone):
- Zone 1:网关(Gateway)。只做认证 + 粗粒度授权 + 限流。目标是拦截「明显非法」的请求:过期 Token、伪造签名、无权限角色、异常频次。
- Zone 2:服务(Service)。做细粒度授权:对象所有权校验、业务状态校验、ABAC 属性校验。必须独立做,不能信任网关传入的身份头。
- Zone 3:数据库(Database)。做强制隔离:多租户用 Row-Level Security,静态数据用 KMS 加密,敏感字段用列级加密。
三层叠加,任何一层被绕过,其他层仍然起作用。这是纵深防御(Defense in Depth)的核心思想。第七节会更具体讨论这个话题。
二、Token 验证放在哪
JWT 验证是网关认证里最常见的动作,但「在哪里验证」有三种主流方案,各有取舍。
2.1 方案一:网关集中验证
网关接到请求后,取出
Authorization: Bearer <token>,向 IdP 的
/.well-known/jwks.json
拉取公钥(并缓存),校验签名与标准 claims,把 decode 后的
claims 以 HTTP
头的方式(X-User-ID、X-User-Roles、X-Tenant-ID)转发给下游服务。
优点:
- 逻辑集中,所有服务共享同一套验证配置(issuer、audience、时钟漂移容忍度)
- 下游服务不需要 JWT 库,甚至可以是纯内部服务
- 证书轮转只改网关一处
- 可以在网关统一做 Token 黑名单、吊销列表
缺点:
- 网关成为 SPOF,JWKS 端点挂了全站 401
- 最大风险:下游服务直接信任
X-User-ID头。如果内部网络不是完全可信(比如有开发联调入口、内网 SSRF 路径),攻击者可以绕过网关直接伪造这个头。网关必须保证只有自己能到达下游(mTLS、NetworkPolicy),下游必须拒绝任何没有从网关来的请求 - 网关到下游之间的流量如果走明文,中间人可以替换头
下面是 Envoy JWT 验证 filter 的典型配置:
http_filters:
- name: envoy.filters.http.jwt_authn
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
providers:
auth_server:
issuer: "https://auth.example.com"
audiences:
- "api.example.com"
remote_jwks:
http_uri:
uri: "https://auth.example.com/.well-known/jwks.json"
cluster: auth_server
timeout: 5s
cache_duration: 300s
forward: false
payload_in_metadata: "jwt_payload"
from_headers:
- name: Authorization
value_prefix: "Bearer "
rules:
- match:
prefix: "/api/"
requires:
provider_name: auth_server
- match:
prefix: "/public/"
# 公开路径不需要 Token
- name: envoy.filters.http.router几个关键配置点:
forward: false表示验证完成后把 Token 从Authorization头里剥除。如果下游需要 Token 就设true(但这等于让下游重新验证一遍,意义不大)。payload_in_metadata把 decode 后的 payload 放进 Envoy 的 metadata 里,后续 filter(rate limit、ext_authz)可以读到,用来做基于 claim 的限流或 OPA 决策。cache_duration控制 JWKS 缓存时间。太短每次都拉外部,太长 key 轮转后会出现阶段性 401。经验值 5~10 分钟,并在 IdP 端做 key 轮转的 overlap 窗口。
Kong 的 JWT 插件则更传统一些:
plugins:
- name: jwt
config:
key_claim_name: kid
claims_to_verify:
- exp
- nbf
maximum_expiration: 3600
uri_param_names:
- jwt
header_names:
- authorization
cookie_names: []
run_on_preflight: true
secret_is_base64: falseKong 的 JWT 插件有个历史设计:每个 Consumer 挂一个 JWT
credential,kid 用来找
credential。这种模型适合少量静态 issuer 的场景;多 IdP、动态
issuer 的场景要用 jwt-signer
插件(Enterprise)或自己写插件拉 JWKS。
2.2 方案二:Sidecar 验证(服务网格)
在 Istio / Linkerd 这样的服务网格里,每个 Pod 都有一个 Envoy sidecar,sidecar 负责 JWT 验证。请求从网关进来(或服务间调用进来),sidecar 先验证,再交给业务容器。
Istio 的 RequestAuthentication CRD:
apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
name: jwt-example
namespace: production
spec:
selector:
matchLabels:
app: service-a
jwtRules:
- issuer: "https://auth.example.com"
audiences:
- "api.example.com"
jwksUri: "https://auth.example.com/.well-known/jwks.json"
forwardOriginalToken: true
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: require-jwt
namespace: production
spec:
selector:
matchLabels:
app: service-a
action: ALLOW
rules:
- from:
- source:
requestPrincipals: ["https://auth.example.com/*"]RequestAuthentication 只做验证(有 Token
就验,没 Token 不拦),AuthorizationPolicy
才是真正拦截。这是 Istio 一个常被踩的坑:光配
RequestAuthentication 不配
AuthorizationPolicy,匿名请求照样放行。
优点:
- 验证逻辑离服务更近,每个服务的认证策略可以独立配置
- 网关不再是 SPOF,sidecar 分布式承担验证压力
- 东西向流量也能享受相同的认证机制
缺点:
- 资源开销(每个 Pod 一个 Envoy)
- 故障面扩大(sidecar bug 会影响业务 Pod)
- JWKS 缓存分散在每个 sidecar 里,key 轮转复杂
2.3 方案三:服务自己验证
每个服务在应用代码里加 JWT 验证中间件,直接从 Authorization 头读 Token 并验证。
Go 里用 github.com/golang-jwt/jwt/v5 +
github.com/MicahParks/keyfunc 的典型写法:
import (
"net/http"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
)
var jwks keyfunc.Keyfunc
func init() {
var err error
jwks, err = keyfunc.NewDefault([]string{
"https://auth.example.com/.well-known/jwks.json",
})
if err != nil {
panic(err)
}
}
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := r.Header.Get("Authorization")
if len(raw) < 8 || raw[:7] != "Bearer " {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
tok, err := jwt.Parse(raw[7:], jwks.Keyfunc,
jwt.WithIssuer("https://auth.example.com"),
jwt.WithAudience("api.example.com"),
jwt.WithValidMethods([]string{"RS256"}),
)
if err != nil || !tok.Valid {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
claims := tok.Claims.(jwt.MapClaims)
ctx := contextWithUser(r.Context(), claims["sub"].(string))
next.ServeHTTP(w, r.WithContext(ctx))
})
}优点:
- 最大的弹性与可控性(比如自定义的 claim 校验、特殊的密钥格式)
- 不依赖外部组件,在本地开发里容易跑起来
- 故障隔离清晰,每个服务只关心自己的验证
缺点:
- 代码重复,容易出现「A 服务验证了 aud,B 服务没验证」的漏洞
- 升级 JWT 库要推所有服务一遍
- 缓存与错误处理的质量参差不齐
2.4 怎么选
实际工程里通常是组合而不是单选:
- 南北向:网关做 JWT 验证 + 粗粒度授权,剥除 Token 或改成内部 token 透传下去
- 东西向:服务网格 mTLS + SPIFFE 身份(见第五节)
- 关键服务:即使进了内网,也在应用层再验证一次 Token(纵深防御)
对「内网必须绝对可信」的假设越早放弃越好。
三、BFF 模式与身份转换
BFF(Backend for Frontend)最初由 SoundCloud 提出,解决的是「一个 API 同时服务多种前端(Web、iOS、Android、Partner)很难兼顾」的问题。从认证授权的角度看,BFF 是一个非常有价值的身份转换层。
3.1 为什么前端不直接用 JWT
前端(浏览器)直接持有 Access Token 有两个严重问题:
- 存储位置都不安全。localStorage 可被任何 XSS 脚本读取;sessionStorage 同理;Cookie 虽然可以 HttpOnly,但 JS 就再也读不到 Token,没办法自己加 Authorization 头。
- Refresh Token 更敏感,泄露就是长期账号接管。放前端几乎等于公开。
工业界已经达成共识(见 OAuth 2.0 for Browser-Based Apps 草案):SPA 不再推荐直接持有 Access Token / Refresh Token,而是使用 BFF 模式:
Browser → [Session Cookie] → BFF → [JWT for service-A] → Service A
→ [JWT for service-B] → Service B
浏览器只持有一个 HttpOnly + Secure + SameSite=Lax 的 Session Cookie,BFF 在服务端持有真正的 Access Token / Refresh Token,并代理所有后端请求。
3.2 BFF 的身份职责
一个典型的 BFF 会做这几件事:
- Session 管理:把用户的登录态(JWT、Refresh Token、用户偏好)存在服务端 Session Store(Redis)里,只给浏览器发 Session ID
- Token 刷新:Access Token 过期时,BFF 用 Refresh Token 去 IdP 换新的,全程前端无感知
- Token Downscoping:对后端 Service A
的调用只传 scope 为
read:documents的 narrow token,避免 broad token 被某个服务滥用 - 请求聚合:前端要「用户 Profile + 未读通知数 + 当前订单」三个数据源,BFF 并发调用三个服务再拼装返回,减少前端请求数
- 响应塑形:后端返回 100 个字段,前端只要 5 个,BFF 剪枝
3.3 BFF 实现要点
Session Cookie 设置:
Set-Cookie: session_id=abc123;
HttpOnly;
Secure;
SameSite=Lax;
Path=/;
Max-Age=28800
HttpOnly挡 XSSSecure只走 HTTPSSameSite=Lax挡 CSRF(GET 仍然会带,所以敏感操作必须 POST 或额外 CSRF Token)Max-Age配合服务端 session 过期时间
Session → JWT 转换(伪代码):
func proxyToService(w http.ResponseWriter, r *http.Request) {
sid, _ := r.Cookie("session_id")
session, err := sessionStore.Get(r.Context(), sid.Value)
if err != nil || session == nil {
http.Error(w, "unauthorized", 401)
return
}
accessToken := session.AccessToken
if time.Until(session.ExpiresAt) < 30*time.Second {
accessToken, err = refreshAccessToken(session.RefreshToken)
if err != nil {
http.Error(w, "session expired", 401)
return
}
session.AccessToken = accessToken
sessionStore.Save(r.Context(), sid.Value, session)
}
req, _ := http.NewRequestWithContext(r.Context(), r.Method,
"http://service-a"+r.URL.Path, r.Body)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("X-Request-ID", r.Header.Get("X-Request-ID"))
resp, err := httpClient.Do(req)
// ... 转发响应
}CSRF 防护:虽然 SameSite=Lax
能挡大部分情况,但跨站 POST form 仍可能带
Cookie。银行级场景建议加 Double-Submit Cookie 或
X-CSRF-Token 头校验。
3.4 BFF 与 Gateway 的关系
这两者职责不同,但在实现上常混淆:
| 维度 | API Gateway | BFF |
|---|---|---|
| 面向 | 所有客户端 | 特定前端(Web/Mobile/Partner) |
| 部署 | 一个 | 每种前端一个 |
| 认证 | JWT 验证 | Session 管理 + Token 转换 |
| 是否懂业务 | 不懂 | 懂(组合业务 API) |
| 是否有状态 | 通常无状态 | 有(Session Store) |
一种常见的部署拓扑:Browser → Gateway → Web-BFF → 后端服务群。Gateway
做通用 JWT 验证、限流;Web-BFF 做 Session 转
JWT、请求聚合。两者互补。
四、Token Exchange:RFC 8693
RFC 8693 定义了「用一个 Token 换另一个 Token」的 OAuth 2.0 标准扩展。典型场景是上面 BFF 的例子:BFF 手上有一个 broad-scope Token,需要给每个下游服务换一个 narrow-scope 的 Token。
4.1 三种使用模式
- Impersonation(冒充):A 代表 C 去调用 B,B 看到的主体就是 C,审计里也是 C。
- Delegation(委派):A 代表 C 去调用
B,B 知道是 A 在代表 C 操作,审计里能同时看到 A 和 C(通过
actclaim)。这是更合规的做法。 - Downscoping(降级):A 手上有全权 Token,换一个只能做特定操作的 Token 给下游。即使下游服务被攻破,泄露的 Token 权限有限。
4.2 请求格式
POST /oauth/token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <bff-client-credentials>
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<原始用户 access_token>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&requested_token_type=urn:ietf:params:oauth:token-type:access_token
&audience=service-b
&scope=read:documents
关键参数:
grant_type固定为urn:ietf:params:oauth:grant-type:token-exchangesubject_token是要被换的原 Tokensubject_token_type指明原 Token 类型(access_token、id_token、saml1、saml2、jwt)requested_token_type指明想换成什么类型audience指明目标受众(下游服务)scope指明请求的 scope(必须是原 Token scope 的子集,由 IdP 强制)- 可选的
actor_token表示「实际发起方」,用于 delegation
4.3 响应
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 300,
"scope": "read:documents"
}换来的 Token 通常寿命更短(几分钟),audience 精确,scope 最小化。
4.4 Delegation 里的
act claim
当 A 代表 C 调用 B 时,换来的 JWT 里会有:
{
"iss": "https://auth.example.com",
"sub": "user-c-id",
"aud": "service-b",
"scope": "read:documents",
"exp": 1735689600,
"act": {
"sub": "service-a-id"
}
}act claim 表明「这个 Token 的主体是
C,但实际操作是 A 代表 C 发起的」。审计系统需要同时记录
sub 和 act.sub。
4.5 Keycloak / Auth0 支持情况
- Keycloak:从 14 版本起支持 Token
Exchange(默认 preview feature,需要
--features=token-exchange启动)。配置相对繁琐:每个客户端要开启 permission,细到哪些客户端可以被哪些客户端 exchange。 - Auth0:通过 Token Vault / Custom Token Exchange 支持,但具体 grant_type 需要在 tenant 里启用。
- 自研 IdP:可以自己实现,核心是强校验 subject_token 合法性、强校验 requested scope 是 subject scope 的子集、audience 白名单。
4.6 什么时候用 Token Exchange
不是所有场景都需要 Token Exchange。评估维度:
- 多服务链路且需要精确 audience:适合用
- 服务链路短(≤ 2 跳)且信任边界模糊:简单 JWT 透传即可
- 合规要求高(金融、医疗):强烈推荐,审计更清晰
警惕:Token Exchange 要调 IdP,是网络往返。在请求热路径上做 exchange 会增加延迟,通常会在 BFF 层缓存换来的 token(在过期前复用)。
五、服务间身份传播
服务调服务时,身份怎么传?这是微服务里最容易出问题的环节。常见三种方案,各自适用场景不同。
5.1 JWT 透传
A 把收到的用户 JWT 原封不动塞进对 B 的请求里。
req.Header.Set("Authorization", r.Header.Get("Authorization"))优点:简单,不需要额外基础设施。
致命缺点:
- audience 对不上。用户 Token 的
aud=api.example.com,但现在送到 B 服务,B 的受众校验要么放水(等于不校验),要么通过环境变量把各种 aud 白名单起来(等于没 aud)。 - 作用域放大。用户给前端签的 Token 通常
scope 很宽(
full),B 服务其实只需要read:orders,一旦 B 被攻破,攻击者拿到的是 full scope Token。 - 没有服务自身身份。B 只知道「有个用户想做事」,不知道「是谁代表这个用户来的」。无法做「只允许 Service-A 访问这个 API」的策略。
所以 JWT 透传只适合:服务数量少、信任边界清晰、没有合规压力的内部系统。
5.2 mTLS
每个服务一张客户端证书,建立 TLS 连接时双向验证。B
服务从对端证书里读出
spiffe://example.com/ns/prod/sa/service-a 或
CN=service-a,作为调用方身份。
优点:
- 透明加密(不改业务代码)
- 强身份(证书由内部 CA 签发,难伪造)
- 网络层就能拦截非授权调用
缺点:
- 证书管理复杂:分发、轮转、吊销
- 业务身份(用户)需要额外机制传递,mTLS 本身只表达服务身份
具体实现可以参考 工作负载身份:Workload Identity 体系与 SPIFFE 标准。
5.3 SPIFFE / SPIRE
SPIFFE(Secure Production Identity Framework For Everyone)是 CNCF 毕业项目,定义了服务身份的统一格式:
spiffe://trust-domain/ns/default/sa/service-a
trust-domain:信任域,通常是公司或集群名- 后面是 workload selector(命名空间、ServiceAccount 等)
SVID(SPIFFE Verifiable Identity Document)是这个 ID 的具体承载形式,有两种:
- X.509-SVID:X.509 证书,SAN 里放 SPIFFE ID,用于 mTLS
- JWT-SVID:JWT,
sub是 SPIFFE ID,用于非 TLS 链路(比如消息队列)
SPIRE 是参考实现:SPIRE Server 是 CA,SPIRE Agent 跑在每个节点上,基于 workload attestation(容器标签、K8s ServiceAccount、进程信息等)给工作负载签发 SVID。证书短寿命(通常 1 小时),自动轮转。
5.4 Istio 的服务身份
Istio 内置使用 SPIFFE 格式作为服务身份。每个 Pod 启动时,istiod(控制面)会基于 K8s ServiceAccount 给它签发一张 X.509 证书,SAN 里是:
spiffe://cluster.local/ns/production/sa/service-a
sidecar 之间的 mTLS 建立时双向验证这个身份。Istio 的 AuthorizationPolicy 可以直接基于它写策略:
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
name: default
namespace: production
spec:
mtls:
mode: STRICT
---
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: allow-service-a-to-b
namespace: production
spec:
selector:
matchLabels:
app: service-b
rules:
- from:
- source:
principals: ["cluster.local/ns/production/sa/service-a"]
to:
- operation:
methods: ["GET", "POST"]
paths: ["/api/internal/*"]几个要点:
PeerAuthentication开 STRICT 模式,整个 namespace 内的 Pod 之间必须 mTLSAuthorizationPolicy用principals指定允许的源(SPIFFE ID 去掉前缀spiffe://后的部分)to.operation指定允许的方法与路径
这是服务身份 + 细粒度 L7 授权的组合,不需要业务代码改动。
5.5 混合模式
实际生产里常用的混合模式:
- 服务身份:mTLS(或 SPIFFE SVID),由网格负责
- 用户身份:JWT,由应用处理
- 链路上下文:Trace ID + Actor Chain(谁调了谁)
服务 B 收到请求后同时验证两个身份:
- TLS 对端证书 → 谁在调我(Service A)
- Authorization 头的 JWT → 代表哪个用户(User C)
授权决策可以写成:「Service A 可以代表具有
role:editor 的用户调用
/api/documents 的 POST」。
六、Istio、Kong、APISIX 的认证授权能力对比
这三个是国内外微服务环境里最常见的入口 / 网格方案,职责定位不同,能力也不同。
6.1 概览对比
| Feature | Istio/Envoy | Kong | APISIX |
|---|---|---|---|
| JWT 验证 | 内置 filter(jwt_authn) | JWT 插件 | jwt-auth 插件 |
| mTLS | 自动 mesh(SPIFFE 身份) | 手动证书管理 | 手动证书管理 |
| OPA 集成 | ext_authz | OPA 插件 | opa 插件 |
| 细粒度授权 | AuthorizationPolicy CRD | Custom plugin / OPA | Custom plugin / OPA |
| 服务网格 | 是 | 否(纯代理) | 否(纯代理) |
| 管理 API | Kubernetes CRD | REST API | REST API + Dashboard |
| 配置方式 | YAML / CRD | 声明式 / DB / DBless | etcd / YAML |
| 生态 | CNCF 毕业,复杂 | 插件生态大 | 后起之秀,活跃 |
| 延迟开销 | ~1–2ms(多一跳 sidecar) | ~0.5–1ms | ~0.5–1ms |
| 典型部署 | K8s Sidecar / Ambient | VM / K8s | VM / K8s |
6.2 Istio / Envoy
强项:
- 零信任网格天然支持:PeerAuthentication + AuthorizationPolicy + mTLS 一套组合
- ext_authz 强大:可以把授权决策外包给 OPA、自研 PDP、云厂商服务
- 最细粒度:基于源身份、请求属性、JWT claim 任意组合
弱项:
- 运维复杂度高:需要专门团队维护 istiod、sidecar 版本、CRD
- 资源开销:每个 Pod + Envoy sidecar,大规模集群内存占用明显
- 排障链路长:业务 Pod、sidecar、控制面、目标 Pod 的 sidecar 层层转发
- Ambient Mode(无 sidecar)还在演进,生产成熟度待验证
适用:已经在 K8s 上跑、有专职 SRE、需要零信任架构的组织。
6.3 Kong
强项:
- 插件生态成熟:JWT、OIDC、OAuth 2.0 Introspection、Key Auth、LDAP、mTLS 客户端证书、ACL、RBAC 都有官方或社区插件
- Enterprise 版 OIDC 插件功能完善(RP、Session、claim mapping)
- DBless 模式(配置 YAML 推送)便于 GitOps
弱项:
- 服务间 mTLS 需要手动搞(每个服务自己装证书、Kong 只管南北向)
- Lua 插件性能不错但调试复杂
- OPA 集成走外部 HTTP 调用,延迟敏感路径要谨慎
- 细粒度授权基本靠自研插件 + OPA
适用:传统企业、已有 Kong 基础设施、主要关心南北向流量。
6.4 APISIX
强项:
- 基于 etcd 的动态配置,热更新
- 自带 Dashboard(相比 Kong OSS 有优势)
- 插件丰富:jwt-auth、openid-connect、key-auth、opa、authz-keycloak、forward-auth
- 性能在纯代理场景里常年第一梯队
弱项:
- 东西向能力弱(但可以配合 Envoy 用)
- 和 Kong 一样,服务身份与细粒度授权主要靠自研
- 中文社区好,英文生态还在扩张
适用:新项目、性能敏感、需要快速迭代 API 策略。
6.5 关键决策点
- 只做南北向网关:Kong 或 APISIX
- 需要东西向 mTLS + 细粒度 L7 授权:Istio
- 已有 K8s 且团队有能力:Istio 全家桶;否则Kong/APISIX + Linkerd 的轻量组合也是好选择
- 异构环境(部分 VM + 部分 K8s):Kong 更通用
- 云上托管:AWS API Gateway / Azure APIM / 阿里 API 网关能省心,但插件生态与成本要单独评估
七、纵深防御:不能只靠网关
再重申一次:网关不是安全边界,它只是安全链条的第一环。纵深防御的含义是每一层都独立起作用,任何一层被绕过,其他层仍然能拦下来。
7.1 典型绕过路径
- 内部服务直连:开发人员为了联调,暴露了一个
internal-lb,绕过网关。结果这个 LB 忘了下线,被外部扫描到。 - SSRF:某个业务服务里有一个「根据用户提供的
URL 抓取缩略图」的功能,攻击者让它去请求
http://internal-admin-api/delete-all。 - BOLA:完全合法的
Token,
/api/documents/123,但 123 不是自己的文档。网关看 Token 合法就放行,服务没做所有者校验。 - 管控面漏洞:Kong Admin API 暴露到公网(真实事件),攻击者直接改路由。
- sidecar 劫持:网格的 Sidecar 镜像被供应链攻击替换,所有 mTLS 变成假的。
7.2 Swiss Cheese 模型
每一层安全都像一片瑞士奶酪,有洞(漏洞)。单独一片奶酪光很多,但多片叠在一起,洞对齐的概率就低得多。
Gateway (authn, rate limit, coarse authz) [孔洞:被绕过]
↓
Sidecar (mTLS, L7 authz) [孔洞:策略错配]
↓
Service (BOLA 校验, 业务规则) [孔洞:漏写校验]
↓
Database (RLS, 列加密) [孔洞:权限配错]
任何一层都可能有漏洞,但同时全部漏光的概率指数级下降。
7.3 落地建议
- 网关层:JWT 签名 + aud + iss + exp/nbf 必验;粗粒度 role 检查;限流。
- 网格层:STRICT mTLS;基于源身份的白名单(只允许特定服务访问特定路径)。
- 应用层:每一个写操作、每一个读取他人数据的操作,都在业务代码里显式做所有者 / 租户 / 角色校验。不要信任任何来自请求头的身份信息(除非是从 mTLS 对端证书里解析出来的)。
- 数据库层:多租户表开 RLS(Row-Level Security);敏感字段用 KMS 加密;审计日志独立存储。
- 监控层:审计日志里包含
principal_sub、actor_sub、resource_id、decision。出现任何异常(同一个用户短时间内访问大量不同资源)要能告警。
具体的 API 层攻击面参见 API 安全:从 OWASP API Top 10 说起。
八、工程坑点
下面这些坑,大概率每个做过 API 网关 + 微服务授权的团队都踩过至少一两个。
8.1 信任网关传入的
X-User-ID 头
最常见也最危险。网关验证完 JWT,把
X-User-ID: 12345
塞给下游。下游直接读这个头,不验证也不校验来源。
攻击者只要绕过网关(内部
LB、SSRF、开发环境),就能随便伪造 X-User-ID
指定为任意用户。
解决:
- 网关到下游强制 mTLS,下游只接受来自网关的连接(NetworkPolicy + SPIFFE 身份校验)
- 或者:下游也做一层 JWT 验证,不依赖头
- 或者:网关给下游签一个内部 Token(签名密钥只有网关和下游知道),下游校验签名
8.2 JWKS 缓存过久
网关缓存 JWKS 公钥减少 IdP 调用,但 IdP 轮转密钥时,网关在 cache TTL 内还在用旧的 key 验新 Token,全站 401。
解决:
- cache_duration 设 5~10 分钟,不要设 1 天
- IdP 侧做 key overlap:新 key 提前发布到 JWKS,老 key 再保留一段时间
- 网关侧实现 on-demand 刷新:遇到
kid找不到时主动拉一次 JWKS(只此一次,防止刷爆 IdP)
8.3 Audience 对不上
服务 A 用的 Token aud=api.example.com,服务
B 收到后要么直接接受(根本不校验 aud),要么把 aud
白名单开得很宽。
结果:用户给 Web 前端签的 Token 能被任何下游服务接受,作用域失控。
解决:每个服务精确校验自己的
aud,链路里需要跨服务调用就走 Token Exchange 换
aud。
8.4 忘记开 AuthorizationPolicy
Istio 的 RequestAuthentication 只验证
Token,不拦截匿名请求。必须配合
AuthorizationPolicy action: DENY(或 ALLOW +
默认 deny)才是真正的拦截。
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: deny-anonymous
namespace: production
spec:
selector:
matchLabels:
app: service-a
action: DENY
rules:
- from:
- source:
notRequestPrincipals: ["*"]这段的意思:没有 request principal(即没 JWT)就拒绝。
8.5 Gateway Bypass
某些内部系统在 K8s 里用 Service ClusterIP 互相调用,压根不经过网关。开发人员复制了一段别人的代码,结果这个原本该走网关的调用也直连了,跳过了所有认证。
解决:
- 用 NetworkPolicy 限制 Pod 间流量
- 或者:网格强制所有东西向走 Sidecar,关键服务上 AuthorizationPolicy
8.6 mTLS 证书过期
SPIRE 默认 SVID 1 小时过期自动轮转没问题,但如果用自己签的证书 + 手动部署,很容易忘。某日某服务的证书过期,依赖它的服务全部 mTLS 握手失败,级联超时。
解决:
- 证书过期时间监控(Prometheus + cert-exporter)
- 优先用自动轮转(cert-manager、SPIRE)
- 证书尽量短寿命,长寿命证书遗忘的风险大
8.7 Token Exchange 热路径延迟
每个请求都去 IdP 换一次 Token,每次增加几十 ms 网络 + IdP 处理。高 QPS 系统瞬间把 IdP 压垮。
解决:
- BFF 缓存 exchanged token(key = user_id + downstream_audience),在过期前复用
- 批处理:相同 subject + audience 的并发请求合并为一次 exchange
- 对极低延迟路径:考虑用本地签发的短寿命 Token(自建 PDP)而不是 Exchange
8.8 Refresh Token 吊销未生效
OAuth Refresh Token 通常比较长寿命,用户修改密码后如果 IdP 没把旧的 refresh token 吊销,攻击者用偷来的 refresh token 仍能一直拿新的 access token。
解决:
- 密码修改、强制登出时吊销所有该用户的 refresh token(家族化 rotation)
- Refresh Token Rotation:每次 refresh 返回新的 refresh token 并作废旧的,发现旧 token 被用就家族作废
8.9 OPA 策略里的性能陷阱
OPA 策略里不小心写了全表扫描(比如对一个大的 user-role
映射做
some i; data.users[i].roles[_] == "admin"),每次请求消耗
CPU。
解决:
- 用 partial evaluation 预编译
- 大数据集用 external data source(opa-kafka、bundles)按需加载
- 压测授权热路径,纳入容量规划
8.10 审计日志里只有 sub
当通过 Token Exchange / Delegation
进行跨主体操作时,审计日志只记录 sub 会丢失实际
actor。必须记录 act / actor_token
提取出来的信息。
九、选型建议
下面是针对不同规模 / 场景的决策建议。
9.1 小团队、单体 + 几个附属服务
- 网关:Nginx / Traefik / Cloudflare(托管)
- 认证:选一个 IdP(Auth0、Keycloak、Authentik)
- Token:JWT,网关验证 + 剥除,下游不校验
- 服务间:K8s ClusterIP + 内网信任(加 NetworkPolicy 即可)
- 数据库:业务代码里显式租户 / 所有者校验
够用、便宜、维护成本低。
9.2 中等规模(20~100 服务)
- 网关:Kong 或 APISIX,按团队熟悉度选
- BFF:每种前端一个
- 认证:Keycloak / 自建 IdP
- 服务间:开始上 mTLS(Linkerd 更简单,Istio 更强大)
- 关键服务:应用层也验 JWT(纵深防御)
- 授权:OPA + Sidecar 或 OPAL 同步策略
9.3 大规模 / 多云 / 合规场景
- 网关:Istio IngressGateway + 云厂商 WAF
- 网格:Istio Ambient / 传统 sidecar,全 mTLS
- 身份:SPIFFE/SPIRE + 内部 IdP
- 授权:OPA / Cedar,策略即代码(策略版本化、CI 检查)
- Token Exchange:全面使用,严格 audience 管理
- 审计:统一日志流(Actor + Subject + Resource + Decision + TraceID),SIEM 告警
9.4 决策速查
- 团队 < 3 人运维:别上 Istio
- 没有 K8s:别上 Istio(Ambient 对 VM 支持弱)
- 只做 API 不做服务网格:Kong / APISIX
- 金融 / 医疗合规:必须 Token Exchange + mTLS + 独立审计
- 浏览器前端:BFF + HttpOnly Cookie,拒绝 Token 落地前端
同系列文章中,B2B SaaS 多租户权限设计 讨论了多租户维度的授权模型;工作负载身份:Workload Identity 体系与 SPIFFE 标准 详细展开 SPIFFE/SPIRE;API Gateway 架构与实现 从架构角度讲网关的职责与模式;API 安全:从 OWASP API Top 10 说起 涵盖 BOLA 等 API 专属风险。
十、参考资料
- RFC 8693 — OAuth 2.0 Token Exchange:https://datatracker.ietf.org/doc/html/rfc8693
- RFC 9068 — JSON Web Token Profile for OAuth 2.0 Access Tokens:https://datatracker.ietf.org/doc/html/rfc9068
- OAuth 2.0 for Browser-Based Apps(Draft):https://datatracker.ietf.org/doc/draft-ietf-oauth-browser-based-apps/
- SPIFFE / SPIRE 官方文档:https://spiffe.io/docs/latest/
- Envoy JWT Authentication Filter:https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/jwt_authn_filter
- Istio Authorization Policy:https://istio.io/latest/docs/reference/config/security/authorization-policy/
- Kong JWT Plugin:https://docs.konghq.com/hub/kong-inc/jwt/
- Apache APISIX jwt-auth Plugin:https://apisix.apache.org/docs/apisix/plugins/jwt-auth/
- OWASP API Security Top 10:https://owasp.org/API-Security/editions/2023/en/0x11-t10/
- Sam Newman — Backends for Frontends:https://samnewman.io/patterns/architectural/bff/
- Open Policy Agent:https://www.openpolicyagent.org/docs/latest/
- NIST SP 800-207 Zero Trust Architecture:https://csrc.nist.gov/pubs/sp/800/207/final
上一篇:B2B SaaS 多租户权限设计
下一篇:Keycloak 工程拆解:Realm、Client、Flow 与扩展机制
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【身份与访问控制工程】OAuth 2.1 与 PKCE:现代授权主路径
从一次 SPA 安全事故出发,系统梳理 OAuth 2.1 相对 2.0 的收敛动作、PKCE 的密码学原理、授权码流程的完整参数细节,以及 DPoP、PAR、JAR、RAR 等现代扩展与常见攻击面
【身份与访问控制工程】RBAC、ABAC、ReBAC:权限模型怎么选
从角色爆炸问题切入,深入对比 RBAC(NIST 四级模型)、ABAC(XACML)、ReBAC(Zanzibar 启发)三种权限模型的数据结构、SQL 建模、策略表达能力与工程取舍,给出混合模型实践与选型决策树。
【身份与访问控制工程】Zanzibar 风格权限系统
深入解析 Google Zanzibar 论文(USENIX ATC 2019)的核心设计:Relation Tuple、Namespace、Userset Rewrite,一致性模型(Zookies)与开源实现 SpiceDB、OpenFGA、Ory Keto 的工程对比,以及适用与不适用场景。
【身份与访问控制工程】B2B SaaS 多租户权限设计
B2B SaaS 的权限问题远比 B2C 复杂:多个企业客户、各自的内部角色体系、跨租户协作、行列级数据权限、租户自助管理。本文从隔离模型出发,给出租户内 RBAC + 租户间 ReBAC 的混合方案、超级管理员设计、行级权限实现,以及 GitHub、Slack、Notion 的权限模型速览。