🏠 목록 Istio 보안은 PeerAuthentication·RequestAuthentication·AuthorizationPolicy 세 리소스가 "transport 인증 · end-user 인증 · 인가"의 역할을 분담한다 📄 MD 원본 🌓 테마
istiosecuritypeerauthenticationrequestauthenticationauthorizationpolicy

Istio 보안은 PeerAuthentication·RequestAuthentication·AuthorizationPolicy 세 리소스가 "transport 인증 · end-user 인증 · 인가"의 역할을 분담한다

ℹ 이 문서가 다루는 것

Istio의 워크로드 보안은 한 리소스가 아니라 세 CRD의 분업으로 성립한다. PeerAuthentication은 서비스 간 transport 계층(mTLS)을 강제해 peer identity(SPIFFE)를 확보하고, RequestAuthentication은 요청에 실린 JWT를 검증해 end-user identity(claim)를 추출하며, AuthorizationPolicy는 앞 둘이 채워준 attribute를 입력받아 허용/차단을 결정한다. 이 문서가 박으려는 단 하나의 멘탈모델: 앞의 두 리소스는 신원을 증명·추출만 하고 자기 자신은 거의 차단하지 않는다 — 실제 게이트는 AuthorizationPolicy 뿐이다. 운영 사고의 대부분은 이 역할 경계를 혼동(PeerAuthentication STRICT를 인가로 착각, RequestAuthentication만 걸고 AuthorizationPolicy 누락)하는 데서 나온다.

00. 배경 — 왜 인증·인가가 "세 조각"으로 쪼개졌나

보안 질문을 한 줄로 줄이면 "이 요청을 처리해도 되는가"다. 그런데 이 한 줄은 사실 성격이 전혀 다른 세 개의 질문을 포개 놓은 것이다.

AuthN (인증) — "너는 정말 너인가?"   ← 신원을 증명하는 단계
  ├─ peer:     이 요청을 *물어다 준 워크로드*가 누구인가?   (service-to-service)
  └─ end-user: 이 요청을 *애초에 일으킨 사람*이 누구인가?   (browser/user)
AuthZ (인가) — "그래서 이걸 해도 되는가?"  ← 증명된 신원으로 허용/차단

여기서 두 가지 직교(orthogonal)하는 분리가 일어난다. 이 직교성이 세 리소스로 쪼개진 근본 이유다.

세 리소스는 정확히 이 세 칸을 하나씩 맡는다. 그래서 셋은 경쟁하지 않고 조립된다.

질문 답하는 리소스 어디서(계층)
물어다 준 워크로드가 누구? PeerAuthentication L4 transport (mTLS cert)
일으킨 사용자가 누구? RequestAuthentication L7 (JWT 헤더)
그래서 허용? 차단? AuthorizationPolicy L7/L4 RBAC
ℹ 선행 개념

이 문서는 mTLS가 어떻게 SPIFFE identity를 peer cert SAN에 박는지는 SPIFFE workload identity에, AuthorizationPolicy의 deny-by-default 평가 내부는 AuthorizationPolicy 멘탈모델에 위임한다. 여기서는 세 리소스가 어떻게 분업·의존하는지의 큰 그림만 다룬다. 대상환경: Istio 1.30, sidecar 모드.

01. 핵심 아키텍처 — "채우는 둘 + 결정하는 하나"

멘탈모델 앵커는 이것 하나다: 세 리소스는 같은 자리(워크로드 inbound Envoy의 filter chain)에 차례로 끼어들지만, 앞 둘은 attribute를 채우기만 하고 마지막 하나만 verdict를 낸다.

세 리소스가 같은 셋업 메커니즘을 공유한다는 점이 이 구조의 출발이다. 셋 다 security.istio.io/v1 apiVersion이고, istiod가 selector로 워크로드를 골라 그 Envoy의 inbound listener에 filter를 주입한다. 다른 건 무엇을 검사하고, 검사 결과로 무엇을 하느냐뿐이다. 한 요청이 inbound sidecar를 통과할 때 세 filter가 끼어드는 순서:

inbound request mTLS handshake peer cert 검증 PeerAuthentication 평문 허용/거부 RequestAuth (JWT) 검증 → claim 추출 AuthorizationPolicy RBAC filter: ALLOW/DENY server app source.principal request.auth.claims
그림 1. 점선이 핵심. 실선은 통과/거부의 데이터 흐름이지만, 점선은 PeerAuthentication·RequestAuthentication이 RBAC에 공급하는 attribute(source.principal, request.auth.claims)다 — 이 공급이 없으면 RBAC은 IP/port/path 같은 raw 정보밖에 못 본다.

점선이 이 그림의 핵심이다. 실선(왼→오른쪽)은 "통과/거부"라는 데이터 흐름이지만, 점선은 "attribute 공급"이다. PeerAuthentication과 RequestAuthentication이 자기 차단(평문 거부, JWT 형식 오류 거부)을 하긴 한다. 하지만 그보다 훨씬 중요한 산출물은 AuthorizationPolicy가 정책 조건으로 쓸 attribute를 채워 넣는 것source.principal(peer가 누구), request.auth.claims(user의 권한)다. 이 점선이 없으면 마지막 RBAC filter는 IP·port·path 같은 L4/L7 raw 정보밖에 못 본다.

이 한 표가 전체 멘탈모델의 압축이다. 세 가지 함정이 여기서 바로 읽힌다.

리소스 계층 검사 대상 산출(authz 입력) 자체 차단 없을 때 기본값
PeerAuthentication L4 transport (mTLS) client peer cert (SPIFFE SVID) source.principal, source.namespace STRICT면 평문 거부 PERMISSIVE (mTLS·평문 모두 수용)
RequestAuthentication L7 (HTTP) Authorization: Bearer <JWT> request.auth.principal, request.auth.claims[...] 잘못된 토큰만 거부 토큰 검증 안 함
AuthorizationPolicy L7/L4 RBAC 위 둘 + IP/port/SNI/method/path — (최종 verdict) ALLOW/DENY ALLOW 0개면 기본 허용
  1. PeerAuthentication 기본값은 PERMISSIVE — 명시적으로 STRICT를 걸지 않으면 평문이 그대로 통과한다.
  2. RequestAuthentication 단독은 게이트가 아니다 — 토큰이 있으면 검증하지만, 토큰이 없는 요청은 그냥 통과시킨다. "JWT 없으면 거부"는 AuthorizationPolicy로 따로 명시해야 한다.
  3. AuthorizationPolicy의 ALLOW 비대칭 — ALLOW 정책이 0개면 기본 허용, 하나라도 생기면 "명시 허용 외 전부 차단"으로 뒤집힌다.

세 함정의 공통 뿌리는 같다: "채우는 것"과 "막는 것"을 혼동한다. 아래 세 절은 각 리소스가 정확히 어느 칸을 채우(거나 막)는지를 메커니즘으로 푼다.

02. PeerAuthentication — transport 인증으로 peer identity를 "존재하게" 한다

PeerAuthentication은 서비스 간(service-to-service) 통신의 transport 계층을 다룬다. 결정하는 것은 단 하나: "이 워크로드의 inbound는 mTLS를 요구하는가, 평문도 받는가".

apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
  name: default
  namespace: payments
spec:
  mtls:
    mode: STRICT          # 이 namespace inbound는 mTLS만 수용, 평문 거부

mode의 의미:

STRICT      : peer cert 없는 평문 connection 거부
PERMISSIVE  : mTLS면 identity 추출, 평문이면 그냥 통과 (기본값·마이그레이션용)
DISABLE     : mTLS 끔 (외부 LB가 이미 TLS 처리 등 특수 상황)

scope — mesh / namespace / workload

같은 리소스가 selector 유무와 namespace에 따라 적용 범위가 달라진다. 이 우선순위가 STRICT 마이그레이션의 핵심이다.

istio-system(root ns) + selector 없음   → mesh 전역 기본 정책
일반 namespace + selector 없음           → 그 namespace 전체
일반 namespace + selector 있음           → 매칭 workload만 (가장 좁음, 우선)

좁은 정책이 넓은 정책을 override한다. 그래서 안전한 STRICT 전환은 "전역 PERMISSIVE 유지 → 특정 워크로드부터 STRICT selector로 좁게 적용 → 검증 후 범위 확대" 순서다. PERMISSIVE를 깔아 둔 채 좁은 STRICT를 점진 확대하면, 아직 평문으로 호출하는 클라이언트를 한 번에 끊지 않으면서 한 워크로드씩 전환할 수 있다.

왜 STRICT가 인가의 "전제"이지 인가 자체가 아닌가

여기가 이 리소스에서 가장 많이 헷갈리는 지점이다. PeerAuthentication STRICT는 평문을 막을 뿐, 누가 들어올 수 있는지는 결정하지 않는다. STRICT 하에서도 mesh 안의 모든 워크로드는 (각자 유효한 SPIFFE cert를 가지므로) 여전히 서로 호출할 수 있다. "productpage SA만 reviews를 호출 가능"이라는 제약은 AuthorizationPolicy의 principals 조건으로만 표현된다.

PeerAuthentication STRICT  = "평문 금지, 모두 mTLS로"  (identity가 *존재*하게 만듦)
AuthorizationPolicy        = "그 identity 중 누구만 허용" (identity로 *차별*함)

이 둘의 의존 관계가 결정적이다. AuthorizationPolicy에 principals를 써도 PeerAuthentication이 PERMISSIVE면, 평문 요청은 peer cert가 없어 principal 조건에서 deny될 뿐 "평문 자체가 거부"되는 건 아니다 — 평문으로 들어와 principal이 비어 있으니 그냥 ALLOW에 매칭 안 돼 떨어진 것이고, 공격자가 우연히 빈 principal로 통과되는 정책 빈틈이 있으면 STRICT 없이는 막히지 않는다. 강한 차단은 STRICT가 별도로 보장해야 한다. mTLS가 어떻게 SPIFFE identity를 peer cert SAN에 박아 source.principal의 입력이 되는지는 SPIFFE workload identity를 정본으로 참조한다.

03. RequestAuthentication — end-user 인증으로 JWT claim을 추출한다

PeerAuthentication이 "호출한 서비스가 누구인가"(peer identity)를 다룬다면, RequestAuthentication은 "요청을 일으킨 최종 사용자가 누구인가"(end-user identity)를 다룬다. 둘은 다른 축이다 — 같은 payment-api SA(peer)가 보낸 요청이라도 그 안에 실린 JWT의 사용자(end-user)는 매번 다를 수 있다(00장의 직교성이 여기서 구체화된다).

apiVersion: security.istio.io/v1
kind: RequestAuthentication
metadata:
  name: jwt-on-ingress
  namespace: istio-system
spec:
  selector:
    matchLabels:
      app: istio-ingressgateway
  jwtRules:
  - issuer: "https://accounts.example.com"
    jwksUri: "https://accounts.example.com/.well-known/jwks.json"
    audiences: ["payment-api"]

동작:

Authorization: Bearer <JWT> 헤더 검사
  → issuer / signature(jwksUri의 공개키) / exp / audiences 검증
  → 통과: request.auth.principal = "<issuer>/<sub>"
          request.auth.claims["..."] 채움  ← AuthorizationPolicy 입력
  → 실패(서명/만료 오류): 401 거부
  → 토큰 자체가 없음: 그냥 통과 (검증 대상이 없으므로)

가장 흔한 함정 — "토큰 없으면 통과"

위 동작의 마지막 줄이 핵심 함정이고, 01장 함정 2의 메커니즘이다. RequestAuthentication은 토큰이 있을 때만 검증한다 — 이름이 "Authentication"이라 "인증을 켰으니 막힌다"고 읽히지만, 실제로는 "토큰이 제시되면 그게 진짜인지 본다"일 뿐이다. 토큰을 빼고 요청하면 검증할 대상 자체가 없어 그냥 통과한다. 따라서 RequestAuthentication 하나만 걸고 "이제 JWT 인증이 됐다"고 생각하면, 토큰 없는 요청에 무방비가 된다. "JWT가 있어야 한다"를 강제하려면 AuthorizationPolicy를 짝으로 건다.

apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: require-jwt
  namespace: istio-system
spec:
  selector:
    matchLabels:
      app: istio-ingressgateway
  action: ALLOW
  rules:
  - from:
    - source:
        requestPrincipals: ["*"]     # 유효 JWT principal이 *존재해야* 통과

requestPrincipals: ["*"]는 "RequestAuthentication이 채운 request.auth.principal이 비어 있지 않을 것"을 요구한다. 즉 토큰이 없거나 검증 실패해 principal이 비면 ALLOW에 매칭 안 돼 거부된다. RequestAuthentication(검증) + AuthorizationPolicy(존재 강제)의 2단 구성이 정석 — 이게 "채우는 것"과 "막는 것"의 분리를 가장 적나라하게 보여주는 패턴이다.

요청이 두 인증 리소스를 거쳐 어떤 attribute를 얻는지 그림으로 보면(빈값으로 통과하는 두 경로 PN/JN이 바로 위의 함정 1·2다):

HTTP requestpeer cert?valid JWT?source.principal= SPIFFE SANprincipal 빈값PERMISSIVE있음없음auth.principal+claims빈값 통과401 즉시 거부유효토큰없음오류AuthorizationPolicy
그림 2. peer cert 유무가 source.principal을, JWT 검증 결과가 request.auth.claims를 채움(토큰 오류는 401 즉시 거부). 채워진 값들이 AuthorizationPolicy로 모여 최종 판정.

04. AuthorizationPolicy — 추출된 attribute로 유일한 게이트를 친다

앞의 두 리소스가 채워준 attribute(source.principal, request.auth.claims)와 L4/L7 정보(IP·port·SNI·method·path)를 모아, 유일하게 실제 ALLOW/DENY 결정을 내리는 리소스다. 02·03이 점선으로 흘려보낸 attribute가 여기서 비로소 "조건"으로 소비된다.

apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: allow-productpage-with-user-role
  namespace: default
spec:
  selector:
    matchLabels:
      app: reviews
  action: ALLOW
  rules:
  - from:
    - source:
        principals: ["cluster.local/ns/default/sa/productpage"]  # ← PeerAuth가 채움
    to:
    - operation:
        methods: ["GET"]
        paths: ["/reviews/*"]
    when:
    - key: request.auth.claims[role]                              # ← RequestAuth가 채움
      values: ["reviewer"]

이 한 정책 안에서 peer identity(principals)end-user claim(request.auth.claims[role]) 이 동시에 조건으로 쓰이는 게 세 리소스 분업의 결실이다. principals 값은 PeerAuthentication+mTLS가 없으면 채워지지 않고, request.auth.claims는 RequestAuthentication이 없으면 채워지지 않는다 — AuthorizationPolicy는 앞 둘의 산출물에 의존한다. 두 입력이 빈 채로 이 정책을 걸면 조건이 영영 매칭되지 않아 "정책이 안 먹는다"가 된다(실은 입력이 안 채워진 것).

action과 평가 순서

CUSTOM → DENY → ALLOW   순서로 평가
ALLOW policy 0개  → 기본 허용
ALLOW policy ≥1개 → 매칭만 허용, 나머지 거부 (deny-by-default로 전환)
AUDIT             → 판정에 영향 없음, 매칭 사실만 로그 (병렬 트랙)

deny-by-default의 비대칭(첫 ALLOW가 게이트를 뒤집음 = 01장 함정 3)과 CUSTOM(ext_authz)·TCP DENY 함정 등 평가 메커니즘의 깊은 detail은 AuthorizationPolicy 멘탈모델을 정본으로 위임한다. 여기서는 "AuthorizationPolicy가 trio의 최종 verdict 단계"라는 위치만 못 박는다.

05. 예시 — 세 리소스를 조합해 한 워크로드를 잠그고, 떴는지 확인한다

실제 워크로드 하나를 STRICT mTLS + JWT 필수 + 세분 인가로 잠그는 표준 조합:

1) PeerAuthentication STRICT     → 평문 차단, 모든 inbound mTLS화 (peer identity 확보)
2) RequestAuthentication         → JWT 서명/issuer/audience 검증 (claim 추출)
3) AuthorizationPolicy (require)  → requestPrincipals:["*"]로 JWT 존재 강제
4) AuthorizationPolicy (allow)    → principals + claims + method/path로 세분 허용

순서가 곧 의존 순서다. 1·2가 attribute를 채워야 3·4의 조건이 의미를 갖는다 — 01장 점선(공급) → 04장 소비(조건)의 시간축 버전이다. 적용 후 검증(아래 reviews 예시 트래픽은 9080 포트의 평문 HTTP로, AuthorizationPolicy의 methods/paths 같은 L7 조건이 실제로 평가되는 경로다):

# 1) STRICT 반영 — 평문 호출이 거부되는지
#    주의: sleep은 sidecar 미주입(또는 주입 비활성 ns)이어야 평문이 실제로 나간다.
#    주입돼 있으면 auto-mTLS로 업그레이드돼 거부 대신 200이 나와 테스트가 무의미해진다.
kubectl exec deploy/sleep -n test -- curl -sI http://reviews.default:9080/ -o /dev/null -w "%{http_code}\n"
# 기대: 000 (connection reset) 또는 56 — 평문이 mTLS listener에서 끊김

# 2) mTLS 경유(sidecar) 정상 호출 — peer principal이 채워져 ALLOW 매칭
kubectl exec deploy/productpage -n default -- curl -sI http://reviews.default:9080/reviews/1 -o /dev/null -w "%{http_code}\n"
# 기대: 200

# 3) JWT 없는 요청 거부 (require-jwt 정책)
kubectl exec deploy/sleep -n test -- curl -sI http://ingress/api -o /dev/null -w "%{http_code}\n"
# 기대: 403  (RBAC: access denied — requestPrincipals 매칭 실패)

# 4) Envoy에 RBAC filter가 실제로 주입됐는지
istioctl proxy-config listener reviews-xxxxx.default -o json | grep -i rbac
# 기대: "envoy.filters.http.rbac" 항목 존재

각 줄이 검증하는 게 다르다는 점이 중요하다. ①은 PeerAuthentication(평문 거부) 자체를, ②는 mTLS가 source.principal을 채워 ALLOW가 매칭됨을, ③은 RequestAuthentication+require-jwt의 2단 구성(requestPrincipals 미충족 → 403)을, ④는 정책이 말로만이 아니라 실제 Envoy filter로 내려갔는지를 본다. ③의 403과 ①의 000을 구별하라 — 403은 RBAC가 동작해 인가에서 떨어진 것(filter는 통과)이고, 000은 그 앞 transport에서 끊긴 것이다. 둘이 보는 계층이 다르다.

마지막으로 istioctl analyze -A로 세 리소스 간 모순(예: principal 정책인데 PeerAuth가 DISABLE)도 사전 점검한다 — 경고 0이 목표.

핵심 정리

What you might be missing