- Published on
API Gateway에서의 OIDC 토큰 검증 — Istio, Envoy, Gateway API 실전
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 토큰 검증은 어디서 해야 하는가
- 엣지에서 검증 vs 서비스에서 검증
- Envoy jwt_authn 필터 상세
- Istio — RequestAuthentication + AuthorizationPolicy
- JWKS 캐싱과 장애 모드
- Audience 전략
- 토큰 전파 — 원 토큰 전달 vs Token Exchange
- Kong vs APISIX — OIDC 플러그인 관점 비교
- Gateway API 시대의 인증 표준화
- mTLS와 JWT의 조합 — 서비스 신원과 사용자 신원
- 성능 관점
- 트러블슈팅 — 401 디버깅 플로우차트
- 마치며
- 참고 자료
들어가며 — 토큰 검증은 어디서 해야 하는가
마이크로서비스 아키텍처에서 가장 자주 반복되는 보안 논쟁 중 하나는 "JWT 검증을 어디서 할 것인가"입니다. 게이트웨이에서 한 번만? 모든 서비스에서? 둘 다? 2026년 현재 이 질문에 대한 업계의 합의는 비교적 명확해졌습니다. 엣지에서 거르고, 서비스에서 다시 검증한다(defense in depth) 입니다. 그리고 그 구현 수단으로 Envoy 기반 스택(Istio, Envoy Gateway, Gloo 등)이 사실상의 표준이 되었습니다.
이 글에서는 Envoy의 jwt_authn 필터를 바닥부터 이해하고, Istio의 RequestAuthentication + AuthorizationPolicy 조합을 풍부한 YAML 예제로 살펴봅니다. JWKS 캐싱과 장애 모드, audience 전략, 토큰 전파 패턴(RFC 8693 Token Exchange 포함) 같은 운영 난제를 짚고, Kong/APISIX의 OIDC 플러그인 비교, Gateway API 시대의 인증 표준화 흐름, mTLS와 JWT의 조합까지 다룹니다. 마지막에는 401 디버깅 플로우차트를 ASCII로 정리합니다.
엣지에서 검증 vs 서비스에서 검증
두 접근의 트레이드오프를 먼저 정리합니다.
| 항목 | 엣지(게이트웨이)에서만 검증 | 각 서비스에서만 검증 |
|---|---|---|
| 성능 | 검증 1회, 내부는 무비용 | 홉마다 검증 비용 반복 |
| 일관성 | 중앙 정책, 설정 한 곳 | 서비스별 라이브러리/설정 파편화 |
| 내부 침해 대응 | 게이트웨이 우회 시 무방비 | 내부 트래픽도 검증되어 견고 |
| 클레임 활용 | 헤더로 전달 필요(위조 위험 관리) | 서비스가 직접 클레임 접근 |
| 운영 부담 | 낮음 | 라이브러리 버전, JWKS 관리 분산 |
결론은 둘의 조합입니다. 실무 권장 패턴은 다음과 같습니다.
- 엣지(게이트웨이): 서명, 발급자(iss), 만료(exp), audience(aud)를 검증하고 잘못된 트래픽을 일찍 차단합니다. 비싼 내부 자원이 쓰레기 토큰에 낭비되지 않습니다.
- 서비스(사이드카 또는 라이브러리): 같은 검증을 반복하되, 서비스별 audience와 세밀한 인가(스코프, 역할)를 추가합니다. 게이트웨이가 뚫리거나 내부에서 위조된 호출이 와도 방어됩니다.
- 서비스 간 신뢰는 mTLS로: 사용자 토큰과 별개로, 호출자 서비스의 신원은 mTLS(SPIFFE 등)로 증명합니다. 뒤에서 다시 다룹니다.
[엣지: 1차 검증 - 서명/iss/exp/aud]
Client ──> API Gateway (Envoy jwt_authn) ──┐
│ mTLS (서비스 신원)
v
[서비스: 2차 검증 + 세밀한 인가]
Service A (sidecar RequestAuthentication)
│ 토큰 전파 or exchange
v
Service B (sidecar + AuthorizationPolicy)
Envoy jwt_authn 필터 상세
Istio든 Envoy Gateway든 Kong의 일부 모드든, 밑바닥에서 JWT를 검증하는 것은 Envoy의 HTTP 필터인 jwt_authn입니다. 원리를 알면 상위 추상화의 동작과 장애를 정확히 이해할 수 있습니다.
핵심 개념은 두 가지입니다.
- providers — "어떤 발급자의 토큰을, 어떤 키로, 어떻게 검증할 것인가"의 정의. issuer, audiences, JWKS 소스, 토큰 추출 위치, 페이로드 전달 방식을 지정합니다.
- rules — "어떤 라우트에 어떤 provider를 요구할 것인가"의 매핑. requires로 provider를 지정하며 allow_missing, allow_missing_or_failed 같은 완화 모드도 있습니다.
전체 설정 예시는 다음과 같습니다.
http_filters:
- name: envoy.filters.http.jwt_authn
typed_config:
'@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
providers:
keycloak_provider:
issuer: https://keycloak.example.com/realms/prod
audiences:
- orders-api
remote_jwks:
http_uri:
uri: https://keycloak.example.com/realms/prod/protocol/openid-connect/certs
cluster: keycloak_jwks_cluster
timeout: 3s
cache_duration: 600s
async_fetch:
fast_listener: false
retry_policy:
num_retries: 3
# Authorization: Bearer 헤더에서 추출 (기본값)
from_headers:
- name: Authorization
value_prefix: 'Bearer '
# 검증된 페이로드를 메타데이터로 저장해 후속 필터(RBAC 등)가 활용
payload_in_metadata: jwt_payload
# 검증 후 업스트림에 평문 클레임 헤더로 전달
claim_to_headers:
- header_name: x-jwt-sub
claim_name: sub
- header_name: x-jwt-scope
claim_name: scope
# 업스트림으로 원본 토큰을 제거할지 유지할지
forward: true
# exp 검증의 클럭 스큐 허용
clock_skew_seconds: 30
rules:
# 헬스체크는 토큰 불요
- match:
prefix: /healthz
# 공개 문서 엔드포인트는 토큰이 있으면 검증, 없어도 통과
- match:
prefix: /docs
requires:
requires_any:
requirements:
- provider_name: keycloak_provider
- allow_missing: {}
# 나머지는 모두 필수
- match:
prefix: /
requires:
provider_name: keycloak_provider
설정 항목별 주의점은 다음과 같습니다.
- issuer는 토큰의 iss 클레임과 문자열 단위로 정확히 일치해야 합니다. 후행 슬래시 하나 차이로 401이 납니다.
- remote_jwks의 cluster는 별도로 정의되어야 합니다. Envoy는 JWKS 엔드포인트도 클러스터로 추상화하므로, DNS/TLS 설정이 빠지면 키를 못 가져와 모든 요청이 401이 됩니다.
- async_fetch를 켜면 리스너 기동 시 JWKS를 미리 받아오고 백그라운드에서 갱신합니다. 첫 요청 지연과 JWKS 엔드포인트 순단의 영향을 줄입니다.
- forward: true가 없으면 기본 동작으로 Authorization 헤더가 업스트림에 제거될 수 있습니다(설정 계열에 따라 다름). 토큰 전파가 필요하면 명시하세요.
- claim_to_headers는 평문 헤더입니다. 업스트림은 이 헤더를 신뢰하기 전에 "Envoy를 거치지 않은 트래픽이 불가능한가"를 네트워크 수준에서 보장해야 합니다.
Istio — RequestAuthentication + AuthorizationPolicy
Istio는 위의 jwt_authn을 RequestAuthentication CRD로, 인가를 AuthorizationPolicy CRD로 추상화합니다. 가장 중요한 사실 하나를 먼저 강조합니다.
RequestAuthentication 단독으로는 아무것도 막지 않습니다. 이 리소스는 "토큰이 있으면 검증하라"는 의미일 뿐, 토큰이 아예 없는 요청은 통과시킵니다. 차단하려면 반드시 AuthorizationPolicy로 "유효한 주체(requestPrincipals)가 있는 요청만 허용"을 선언해야 합니다. 이 함정으로 인한 보안 사고가 실제로 빈번합니다.
ingress gateway에서의 1차 검증 설정입니다.
apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
name: ingress-jwt
namespace: istio-system
spec:
selector:
matchLabels:
istio: ingressgateway
jwtRules:
- issuer: https://keycloak.example.com/realms/prod
jwksUri: https://keycloak.example.com/realms/prod/protocol/openid-connect/certs
audiences:
- api-gateway
forwardOriginalToken: true
outputClaimToHeaders:
- header: x-jwt-sub
claim: sub
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: ingress-require-jwt
namespace: istio-system
spec:
selector:
matchLabels:
istio: ingressgateway
action: DENY
rules:
- from:
- source:
notRequestPrincipals: ['*']
to:
- operation:
notPaths: ['/healthz', '/metrics']
DENY + notRequestPrincipals 패턴은 "유효한 토큰 주체가 없는 요청을 거부"하는 표준 관용구입니다. requestPrincipals의 값은 iss와 sub를 슬래시로 이은 형태가 됩니다.
서비스 계층에서는 더 세밀한 인가를 겁니다. orders 서비스에 "쓰기 작업은 orders:write 스코프 필요"를 표현하면 다음과 같습니다.
apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
name: orders-jwt
namespace: orders
spec:
selector:
matchLabels:
app: orders
jwtRules:
- issuer: https://keycloak.example.com/realms/prod
jwksUri: https://keycloak.example.com/realms/prod/protocol/openid-connect/certs
audiences:
- orders-api
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: orders-authz
namespace: orders
spec:
selector:
matchLabels:
app: orders
action: ALLOW
rules:
# 읽기: 인증된 주체면 허용
- from:
- source:
requestPrincipals: ['https://keycloak.example.com/realms/prod/*']
to:
- operation:
methods: ['GET']
paths: ['/orders', '/orders/*']
# 쓰기: orders:write 스코프 요구
- from:
- source:
requestPrincipals: ['https://keycloak.example.com/realms/prod/*']
to:
- operation:
methods: ['POST', 'PUT', 'DELETE']
paths: ['/orders', '/orders/*']
when:
- key: request.auth.claims[scope]
values: ['*orders:write*']
ALLOW 정책이 하나라도 존재하는 워크로드는 "매칭되지 않는 모든 요청 거부"로 동작이 바뀐다는 점도 기억해야 합니다. 정책을 추가하는 순간 기본값이 deny-by-default로 전환됩니다.
JWKS 캐싱과 장애 모드
JWT 검증 시스템의 가용성은 JWKS 엔드포인트 관리에서 갈립니다. 장애 시나리오를 표로 정리합니다.
| 시나리오 | 증상 | 대응 |
|---|---|---|
| JWKS 엔드포인트 순단 | 캐시 만료 후 fetch 실패 → 전면 401 | async_fetch + 캐시 TTL 여유, IdP 다중화 |
| 키 롤테이션 직후 | 새 kid 토큰이 캐시 미스로 401 | IdP는 새 키 공개 후 유예기간을 두고 서명 전환 |
| IdP가 옛 키를 즉시 삭제 | 기존 토큰 전부 401 | 키 retire는 최소 토큰 수명 이상 지연 |
| 게이트웨이 재기동 + IdP 다운 | JWKS 초기 fetch 실패 | fail-open 여부 정책 결정, JWKS를 로컬 파일로 폴백 |
| 클럭 스큐 | 간헐적 exp/nbf 검증 실패 | clock_skew_seconds 설정 + NTP 모니터링 |
운영 권장 사항은 다음과 같습니다.
- 캐시 TTL은 IdP의 키 롤테이션 주기와 함께 설계합니다. 예: 롤테이션 24시간 전 새 키 공개, 캐시 10분 — 이러면 캐시가 낡아도 검증이 깨지지 않습니다.
- Envoy의 local_jwks(파일 기반)를 비상용으로 준비해 두면 IdP 완전 장애 시에도 기존 키로 검증을 지속할 수 있습니다. 단, 폐기된 키가 살아남는 위험과 트레이드오프입니다.
- JWKS fetch 실패율, 캐시 히트율, 401 비율을 메트릭으로 노출하고 알람을 겁니다. Envoy는 jwt_authn 통계(denied, jwks_fetch_failed 등)를 제공합니다.
Audience 전략
aud 클레임은 "이 토큰이 누구를 위한 것인가"를 선언합니다. 전략 선택지는 세 가지입니다.
- 단일 audience (게이트웨이용 하나) — 구현이 단순하지만, 토큰이 어느 서비스에서나 재사용 가능해 토큰 탈취 시 폭발 반경이 큽니다.
- 서비스별 audience — 각 서비스가 자기 audience만 수락합니다. 가장 안전하지만, 클라이언트가 서비스마다 다른 토큰을 받아야 하고 서비스 간 호출 시 token exchange가 필요해집니다.
- 계층형(현실적 절충) — 외부 노출 API 단위로 audience를 나누고, 내부 세분화는 스코프로 처리합니다. 게이트웨이는 광역 audience를, 각 서비스는 자신이 속한 API의 audience + 스코프를 검증합니다.
권장 원칙은 다음과 같습니다.
- 최소한 "이 조직의 토큰이면 다 통과" 같은 audience 미검증 상태는 피합니다. RFC 9700(OAuth Security BCP)도 audience restriction을 핵심 완화책으로 명시합니다.
- access token의 audience는 리소스 서버 기준으로, ID token(aud=클라이언트)은 API 호출에 쓰지 않습니다. ID token을 API에 보내는 것은 흔한 안티패턴입니다.
토큰 전파 — 원 토큰 전달 vs Token Exchange
서비스 A가 사용자 요청을 받아 서비스 B를 호출할 때 사용자 컨텍스트를 어떻게 넘길 것인가의 문제입니다.
패턴 1: 원 토큰 그대로 전달 (token relay)
Client --(JWT aud=api)--> Gateway --(같은 JWT)--> Service A --(같은 JWT)--> Service B
- 장점: 단순, 추가 IdP 왕복 없음.
- 단점: audience가 광역이어야 해서 토큰 탈취 시 모든 서비스가 위험. 토큰 수명 동안 B가 A를 가장해 다른 서비스 호출 가능. 위임 정보가 없어 감사가 불완전.
패턴 2: Token Exchange (RFC 8693)
서비스 A가 받은 토큰을 IdP에 제시하고, audience가 B로 좁혀진 새 토큰으로 교환합니다.
curl -s -X POST https://keycloak.example.com/realms/prod/protocol/openid-connect/token \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d client_id=service-a \
-d client_secret=SERVICE_A_SECRET \
-d subject_token=ORIGINAL_USER_ACCESS_TOKEN \
-d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
-d audience=service-b
응답으로 받는 토큰에는 sub(원 사용자)와 함께 act(actor) 클레임이 들어가 "service-a가 사용자를 대신해 행동"한다는 위임 체인이 기록됩니다.
{
"iss": "https://keycloak.example.com/realms/prod",
"sub": "user-1234",
"aud": "service-b",
"scope": "orders:read",
"act": {
"sub": "service-account-service-a"
},
"exp": 1781234567
}
- 장점: audience 최소화, 위임 체인 보존, 권한 축소(scope down) 가능. AI agent 시대의 위임 추적에도 동일 메커니즘이 쓰입니다.
- 단점: 홉마다 IdP 왕복(캐싱 필수), IdP의 exchange 정책 관리 부담.
실무 절충안은 "신뢰 경계를 넘는 곳(도메인 간, 민감 서비스 진입)에서만 exchange하고, 같은 신뢰 경계 안에서는 relay + mTLS"입니다. 참고로 이 흐름을 표준화한 transaction tokens 논의가 OAuth WG에서 진행 중이며, 다음 글(SPIFFE/SPIRE)에서 워크로드 신원과 함께 다시 다룹니다.
Kong vs APISIX — OIDC 플러그인 관점 비교
Envoy 계열 밖에서 가장 많이 쓰이는 게이트웨이 두 종의 OIDC 처리 방식을 비교합니다.
| 항목 | Kong (openid-connect 플러그인) | APISIX (openid-connect / authz-keycloak) |
|---|---|---|
| 기반 | nginx/OpenResty + lua-resty-openidc | nginx/OpenResty + lua-resty-openidc |
| 라이선스 범위 | OIDC 플러그인은 Enterprise | OSS에 포함 |
| 동작 모드 | 검증(JWT), 세션(쿠키), relying party | 검증, relying party, Keycloak 인가 연동 |
| JWKS 캐싱 | 내장, 디스커버리 캐시 | 내장, 디스커버리 캐시 |
| 세밀한 인가 | ACL/스코프 플러그인 조합 | authz-keycloak로 UMA 권한 평가 위임 |
| 선언적 관리 | decK, Kong CRD | APISIX CRD, ADC |
APISIX 설정 예시는 다음과 같습니다.
apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
name: orders-route
namespace: apps
spec:
http:
- name: orders
match:
hosts:
- api.example.com
paths:
- /orders/*
backends:
- serviceName: orders
servicePort: 8080
plugins:
- name: openid-connect
enable: true
config:
discovery: https://keycloak.example.com/realms/prod/.well-known/openid-configuration
client_id: apisix-gateway
client_secret: GATEWAY_CLIENT_SECRET
bearer_only: true
use_jwks: true
token_signing_alg_values_expected: RS256
audience: orders-api
bearer_only: true는 "브라우저 리다이렉트 없이 Bearer 토큰만 검증"하는 API 게이트웨이 모드입니다. 웹 앱 세션까지 게이트웨이가 처리하게 하려면 bearer_only를 끄고 relying party 모드로 씁니다(이 경우 게이트웨이가 IAP에 가까워집니다).
Gateway API 시대의 인증 표준화
쿠버네티스 Gateway API는 Ingress의 후계자로 라우팅 표현은 표준화했지만, 인증/인가는 오랫동안 구현체별 확장(정책 CRD)의 영역이었습니다. 2025~2026년의 흐름은 다음과 같습니다.
- Gateway API 1.4에서 BackendTLSPolicy 등 정책 부착(Policy Attachment) 패턴이 자리 잡았고, 인증 필터의 표준화 논의(HTTPRoute 수준의 JWT/extAuth 필터)가 GEP(Gateway Enhancement Proposal)로 진행 중입니다.
- 그 전까지 실무는 구현체별 정책 CRD를 씁니다. Envoy Gateway의 SecurityPolicy가 대표적입니다.
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
name: orders-jwt
namespace: apps
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: HTTPRoute
name: orders-route
jwt:
providers:
- name: keycloak
issuer: https://keycloak.example.com/realms/prod
audiences:
- orders-api
remoteJWKS:
uri: https://keycloak.example.com/realms/prod/protocol/openid-connect/certs
claimToHeaders:
- claim: sub
header: x-jwt-sub
같은 Envoy 기반이라도 Istio는 자체 CRD, Envoy Gateway는 SecurityPolicy, Gloo는 또 다른 CRD를 쓰는 파편화가 현재의 현실입니다. 다만 모두 밑단은 jwt_authn 필터이므로, 이 글 앞부분의 원리 이해가 모든 구현체에 통합니다. 장기적으로는 HTTPRoute에 인증 필터를 표준 문법으로 붙이는 방향이 유력합니다.
mTLS와 JWT의 조합 — 서비스 신원과 사용자 신원
mTLS와 JWT는 경쟁 관계가 아니라 서로 다른 질문에 답하는 직교적 수단입니다.
- mTLS (peer identity) — "이 요청을 보낸 워크로드는 누구인가". Istio에서는 PeerAuthentication으로 강제하고, 신원은 SPIFFE 형식의 principal로 표현됩니다.
- JWT (request identity) — "이 요청이 대변하는 최종 사용자는 누구인가".
둘을 조합한 AuthorizationPolicy가 Zero Trust 마이크로서비스의 표준형입니다.
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: default
namespace: orders
spec:
mtls:
mode: STRICT
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: orders-payment-call
namespace: payments
spec:
selector:
matchLabels:
app: payments
action: ALLOW
rules:
- from:
- source:
# 호출자 워크로드 제한 (mTLS 기반 서비스 신원)
principals: ['cluster.local/ns/orders/sa/orders-sa']
# 최종 사용자 토큰도 함께 요구
requestPrincipals: ['https://keycloak.example.com/realms/prod/*']
to:
- operation:
methods: ['POST']
paths: ['/payments']
when:
- key: request.auth.claims[scope]
values: ['*payments:write*']
이 정책은 "orders 서비스 계정의 워크로드가, 유효한 사용자 토큰과 payments:write 스코프를 가지고, POST /payments를 호출할 때만 허용"을 한 장에 표현합니다. 서비스 신원(mTLS)과 사용자 신원(JWT)의 이중 검증입니다.
성능 관점
JWT 검증 비용에 대한 일반적인 관찰을 정리합니다(절대 수치는 환경 의존이므로 직접 벤치마크 권장).
- RS256 서명 검증은 요청당 수십 마이크로초 수준으로, p50 지연에는 거의 영향이 없습니다. ES256/EdDSA는 검증이 더 가볍고 키도 짧아 2026년 신규 구축에서 선호됩니다(Keycloak 26.6은 EdDSA를 지원합니다).
- 진짜 비용은 서명 연산이 아니라 JWKS fetch가 요청 경로에 끼어드는 순간입니다. async_fetch와 캐시로 요청 경로에서 분리하는 것이 핵심입니다.
- 사이드카 2차 검증의 추가 지연은 일반적으로 홉당 1ms 미만으로, defense in depth의 가치 대비 수용 가능한 수준입니다.
- 토큰 크기는 간과되는 비용입니다. 그룹/권한을 모두 클레임에 담아 8KB를 넘기면 헤더 한도 초과로 4xx가 나는 사고가 흔합니다. 클레임은 식별자 위주로 얇게 유지하고, 세밀한 권한은 OpenFGA 같은 별도 인가 서비스에 위임하는 것이 추세입니다.
트러블슈팅 — 401 디버깅 플로우차트
게이트웨이 401의 원인을 체계적으로 좁히는 절차입니다.
+--------------------------+
| 401 발생 |
+-----------+--------------+
|
토큰이 요청에 실려 있는가? (헤더 확인)
|
+----------- 아니오 ---+--- 예 -----------+
| |
클라이언트/프록시가 Authorization jwt.io 등으로 토큰 디코드(검증 말고 관찰)
헤더를 누락/제거? (프록시 체인 점검) |
+------------------+------------------+
| | |
iss가 설정과 exp가 지났는가? aud가 설정과
정확히 일치? (클럭 스큐 포함) 일치하는가?
| | |
불일치: trailing 만료: 갱신 로직 불일치: audience
slash, http/https, 점검, NTP 확인 매핑 재설계
realm 경로 확인
|
모두 정상이면 → 키 검증 단계 의심
|
+------------------+-------------------+
| |
토큰 헤더의 kid가 JWKS에 게이트웨이가 JWKS를
존재하는가? (curl로 JWKS 확인) 가져올 수 있는가?
| |
없음: 키 롤테이션 직후 캐시 fetch 실패: 클러스터 정의,
문제 → 캐시 TTL/롤테이션 유예 DNS, egress 정책, TLS 신뢰
정책 점검 체인 점검
|
모두 정상인데 401 → RequestAuthentication은 통과하고
AuthorizationPolicy에서 거부(403일 수도)인지,
룰 매칭(경로/메서드/스코프)을 점검
함께 쓰는 진단 명령 모음입니다.
# 1) 토큰 페이로드 확인 (서명 검증 없이 디코드)
TOKEN=eyJhbGciOi...
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
# 2) JWKS 직접 조회 — kid 목록 확인
curl -s https://keycloak.example.com/realms/prod/protocol/openid-connect/certs | jq '.keys[].kid'
# 3) Istio 설정이 Envoy에 반영됐는지 확인
istioctl proxy-config listener deploy/istio-ingressgateway -n istio-system -o json \
| jq '.. | select(.name? == "envoy.filters.http.jwt_authn")'
# 4) Envoy 액세스 로그에서 거부 사유 플래그 확인
kubectl logs deploy/istio-ingressgateway -n istio-system | grep -E '401|403' | tail -5
# 5) jwt_authn 통계로 어느 단계에서 막히는지 확인
kubectl exec deploy/istio-ingressgateway -n istio-system -- \
pilot-agent request GET stats | grep -E 'jwt_authn|jwks'
401과 403의 구분도 중요합니다. Istio 기준으로 401은 RequestAuthentication(토큰 자체의 문제), 403은 AuthorizationPolicy(토큰은 유효하나 권한 부족)에서 발생합니다. 디버깅의 첫 분기점으로 삼으세요.
마치며
API Gateway 계층의 OIDC 토큰 검증은 "엣지에서 거르고 서비스에서 다시 검증"이라는 원칙 위에, Envoy jwt_authn이라는 공통 기반으로 수렴했습니다. 정리하면 다음과 같습니다.
- RequestAuthentication은 차단하지 않습니다. AuthorizationPolicy까지가 한 세트입니다.
- 가용성은 JWKS 캐싱 설계에서 갈립니다. async fetch, TTL, 키 롤테이션 유예를 묶어 설계하세요.
- audience는 좁게, 토큰은 얇게, 신뢰 경계를 넘을 때는 token exchange를 검토하세요.
- mTLS(서비스 신원)와 JWT(사용자 신원)는 조합해야 Zero Trust가 완성됩니다.
다음 글에서는 이 글의 mTLS 쪽 절반, 즉 SPIFFE/SPIRE 기반 워크로드 아이덴티티를 깊게 다룹니다.
참고 자료
- Envoy jwt_authn 필터 문서
- Istio Security 개념 문서
- Istio RequestAuthentication 레퍼런스
- Istio AuthorizationPolicy 레퍼런스
- Kubernetes Gateway API
- Envoy Gateway SecurityPolicy 문서
- RFC 7519 — JSON Web Token (JWT)
- RFC 8693 — OAuth 2.0 Token Exchange
- RFC 9700 — Best Current Practice for OAuth 2.0 Security
- RFC 8725 — JWT Best Current Practices
- OpenID Connect Core 1.0
- OAuth 2.1 draft (draft-ietf-oauth-v2-1)
- Keycloak 공식 문서
- Apache APISIX openid-connect 플러그인
- Kong OpenID Connect 플러그인
- OpenFGA 문서