---
type: note
tags: [istio, security, peerauthentication, requestauthentication, authorizationpolicy]
created: 2026-06-07
---
# Istio 보안은 PeerAuthentication·RequestAuthentication·AuthorizationPolicy 세 리소스가 "transport 인증 · end-user 인증 · 인가"의 역할을 분담한다
> [!abstract] 이 문서가 다루는 것
> Istio의 워크로드 보안은 한 리소스가 아니라 **세 CRD의 분업**으로 성립한다. PeerAuthentication은 서비스 간 transport 계층(mTLS)을 강제해 **peer identity**(SPIFFE)를 확보하고, RequestAuthentication은 요청에 실린 JWT를 검증해 **end-user identity**(claim)를 추출하며, AuthorizationPolicy는 앞 둘이 채워준 attribute를 입력받아 **허용/차단**을 결정한다. 이 문서가 박으려는 단 하나의 멘탈모델: **앞의 두 리소스는 신원을 *증명·추출*만 하고 자기 자신은 거의 차단하지 않는다 — 실제 게이트는 AuthorizationPolicy 뿐**이다. 운영 사고의 대부분은 이 역할 경계를 혼동(PeerAuthentication STRICT를 인가로 착각, RequestAuthentication만 걸고 AuthorizationPolicy 누락)하는 데서 나온다.
## 00. 배경 — 왜 인증·인가가 "세 조각"으로 쪼개졌나
보안 질문을 한 줄로 줄이면 "**이 요청을 처리해도 되는가**"다. 그런데 이 한 줄은 사실 성격이 전혀 다른 세 개의 질문을 포개 놓은 것이다.
```text
AuthN (인증) — "너는 정말 너인가?" ← 신원을 증명하는 단계
├─ peer: 이 요청을 *물어다 준 워크로드*가 누구인가? (service-to-service)
└─ end-user: 이 요청을 *애초에 일으킨 사람*이 누구인가? (browser/user)
AuthZ (인가) — "그래서 이걸 해도 되는가?" ← 증명된 신원으로 허용/차단
```
여기서 두 가지 직교(orthogonal)하는 분리가 일어난다. 이 직교성이 세 리소스로 쪼개진 근본 이유다.
- **AuthN과 AuthZ의 분리.** 신원을 *증명하는 일*과 그 신원으로 *결정을 내리는 일*은 별개다. 증명은 암호학(cert 서명 검증, JWT signature 검증)의 영역이고, 결정은 정책(누가 무엇을 해도 되는가)의 영역이다. 한 리소스에 섞으면 "인증을 켰으니 막힌 거겠지"라는 치명적 착각이 생긴다 — 인증은 *누구인지*를 알려줄 뿐, 막는 건 인가다.
- **peer와 end-user의 분리.** 같은 `payment-api` 워크로드(peer)가 보낸 connection 하나 안에서도, 그 위에 실린 JWT의 사용자(end-user)는 요청마다 다르다. 서비스 신원은 L4 transport(mTLS cert)에 박히고, 사용자 신원은 L7 헤더(`Authorization: Bearer`)에 실린다 — 물리적으로 다른 계층에 있으니 다른 메커니즘으로 확보해야 한다.
세 리소스는 정확히 이 세 칸을 하나씩 맡는다. 그래서 셋은 *경쟁*하지 않고 *조립*된다.
| 질문 | 답하는 리소스 | 어디서(계층) |
|---|---|---|
| 물어다 준 워크로드가 누구? | PeerAuthentication | L4 transport (mTLS cert) |
| 일으킨 사용자가 누구? | RequestAuthentication | L7 (JWT 헤더) |
| 그래서 허용? 차단? | AuthorizationPolicy | L7/L4 RBAC |
> [!note] 선행 개념
> 이 문서는 mTLS가 *어떻게* SPIFFE identity를 peer cert SAN에 박는지는 [SPIFFE workload identity](sec__note-mtls-spiffe-identity.html)에, AuthorizationPolicy의 deny-by-default 평가 내부는 [AuthorizationPolicy 멘탈모델](sec__src-authorizationpolicy-mental-model.html)에 위임한다. 여기서는 **세 리소스가 어떻게 분업·의존하는지**의 큰 그림만 다룬다. 대상환경: 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가 끼어드는 순서:
```mermaid
flowchart LR
REQ[inbound request] --> TLS[mTLS handshake
peer cert 검증]
TLS --> PA[PeerAuthentication
평문 허용/거부 결정]
PA --> JWT[RequestAuthentication
JWT 검증 → claim 추출]
JWT --> RBAC[AuthorizationPolicy
RBAC filter: ALLOW/DENY]
RBAC --> APP[server app]
PA -.source.principal.-> RBAC
JWT -.request.auth.claims.-> RBAC
```
**점선이 이 그림의 핵심이다.** 실선(왼→오른쪽)은 "통과/거부"라는 데이터 흐름이지만, 점선은 "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 ` | `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를 요구하는가, 평문도 받는가".
```yaml
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: default
namespace: payments
spec:
mtls:
mode: STRICT # 이 namespace inbound는 mTLS만 수용, 평문 거부
```
mode의 의미:
```text
STRICT : peer cert 없는 평문 connection 거부
PERMISSIVE : mTLS면 identity 추출, 평문이면 그냥 통과 (기본값·마이그레이션용)
DISABLE : mTLS 끔 (외부 LB가 이미 TLS 처리 등 특수 상황)
```
### scope — mesh / namespace / workload
같은 리소스가 selector 유무와 namespace에 따라 적용 범위가 달라진다. 이 우선순위가 STRICT 마이그레이션의 핵심이다.
```text
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` 조건으로만 표현된다.
```text
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](sec__note-mtls-spiffe-identity.html)를 정본으로 참조한다.
## 03. RequestAuthentication — end-user 인증으로 JWT claim을 추출한다
PeerAuthentication이 "**호출한 서비스가 누구인가**"(peer identity)를 다룬다면, RequestAuthentication은 "**요청을 일으킨 최종 사용자가 누구인가**"(end-user identity)를 다룬다. 둘은 다른 축이다 — 같은 `payment-api` SA(peer)가 보낸 요청이라도 그 안에 실린 JWT의 사용자(end-user)는 매번 다를 수 있다(00장의 직교성이 여기서 구체화된다).
```yaml
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"]
```
동작:
```text
Authorization: Bearer 헤더 검사
→ issuer / signature(jwksUri의 공개키) / exp / audiences 검증
→ 통과: request.auth.principal = "/"
request.auth.claims["..."] 채움 ← AuthorizationPolicy 입력
→ 실패(서명/만료 오류): 401 거부
→ 토큰 자체가 없음: 그냥 통과 (검증 대상이 없으므로)
```
### 가장 흔한 함정 — "토큰 없으면 통과"
위 동작의 마지막 줄이 핵심 함정이고, 01장 함정 2의 메커니즘이다. RequestAuthentication은 **토큰이 *있을 때*만 검증**한다 — 이름이 "Authentication"이라 "인증을 켰으니 막힌다"고 읽히지만, 실제로는 "토큰이 *제시되면* 그게 진짜인지 본다"일 뿐이다. 토큰을 빼고 요청하면 검증할 대상 자체가 없어 그냥 통과한다. 따라서 RequestAuthentication 하나만 걸고 "이제 JWT 인증이 됐다"고 생각하면, 토큰 없는 요청에 무방비가 된다. "JWT가 *있어야* 한다"를 강제하려면 AuthorizationPolicy를 짝으로 건다.
```yaml
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다):
```mermaid
flowchart TD
REQ[HTTP request] --> P{peer cert?}
P -->|있음| PP[source.principal 채움
= SPIFFE SAN]
P -->|없음
PERMISSIVE| PN[source.principal 빈값]
REQ --> J{valid JWT?}
J -->|유효| JP[request.auth.principal
request.auth.claims 채움]
J -->|토큰 없음| JN[빈값으로 통과]
J -->|토큰 오류| J401[401 즉시 거부]
PP --> RBAC[AuthorizationPolicy]
PN --> RBAC
JP --> RBAC
JN --> RBAC
```
## 04. AuthorizationPolicy — 추출된 attribute로 유일한 게이트를 친다
앞의 두 리소스가 채워준 attribute(`source.principal`, `request.auth.claims`)와 L4/L7 정보(IP·port·SNI·method·path)를 모아, **유일하게 실제 ALLOW/DENY 결정을 내리는** 리소스다. 02·03이 점선으로 흘려보낸 attribute가 여기서 비로소 "조건"으로 소비된다.
```yaml
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과 평가 순서
```text
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 멘탈모델](sec__src-authorizationpolicy-mental-model.html)을 정본으로 위임한다. 여기서는 "AuthorizationPolicy가 trio의 *최종 verdict 단계*"라는 위치만 못 박는다.
## 05. 예시 — 세 리소스를 조합해 한 워크로드를 잠그고, 떴는지 확인한다
실제 워크로드 하나를 STRICT mTLS + JWT 필수 + 세분 인가로 잠그는 표준 조합:
```text
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 조건이 실제로 평가되는 경로다):
```bash
# 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이 목표.
## 핵심 정리
- **멘탈모델 앵커**: "채우는 둘 + 결정하는 하나". PeerAuth·RequestAuth는 attribute를 *채우고*, 차단은 AuthorizationPolicy가 *한다*. 01장 점선이 채움, 실선이 결정.
- **분업**: PeerAuthentication = transport 인증(peer identity 확보), RequestAuthentication = end-user 인증(JWT claim 추출), AuthorizationPolicy = 인가(최종 ALLOW/DENY). 직교하는 두 분리(AuthN↔AuthZ, peer↔end-user)가 세 칸을 만든다.
- **PeerAuthentication 기본 PERMISSIVE**: STRICT를 명시하지 않으면 평문 통과. STRICT는 "identity를 *존재*하게" 만들 뿐 "누구만 허용"은 AuthorizationPolicy `principals`의 몫.
- **RequestAuthentication 단독은 게이트가 아님**: 토큰이 *있을 때만* 검증, 없으면 통과. JWT 강제는 AuthorizationPolicy `requestPrincipals:["*"]`로 따로(2단 구성).
- **AuthorizationPolicy ALLOW 비대칭**: ALLOW 0개=기본 허용, ≥1개=명시 허용 외 전부 차단(deny-by-default).
- **의존 순서 = 적용 순서**: PeerAuth/RequestAuth가 채운 `source.principal`·`request.auth.claims`가 있어야 AuthorizationPolicy 조건이 동작.
## What you might be missing
- **두 인증의 축이 다르다**: PeerAuthentication은 "호출한 *워크로드*"(service-to-service), RequestAuthentication은 "요청을 일으킨 *최종 사용자*"(end-user). 같은 SA가 보낸 한 connection 안에도 사용자는 매번 다를 수 있다 — 둘을 한 축으로 뭉뚱그리면 "왜 SA 기반 정책으로 사용자별 제어가 안 되지"로 헤맨다.
- **`principals`와 `requestPrincipals`는 다른 필드**: 전자는 PeerAuthentication(mTLS peer cert)에서, 후자는 RequestAuthentication(JWT `iss/sub`)에서 온다. 둘을 헷갈려 JWT 인가에 `principals`를 쓰면 영영 매칭되지 않는다.
- **RequestAuthentication은 ingress gateway에 거는 게 흔하다**: end-user JWT는 보통 외부에서 들어오므로, selector를 `istio-ingressgateway`에 걸어 진입점에서 한 번 검증하고 mesh 내부는 peer identity로 신뢰하는 패턴이 많다. 모든 sidecar마다 JWT 재검증을 강제할 필요는 없다.
- **세 리소스가 모두 같은 LDS/RBAC filter로 내려간다**: 정책이 안 먹을 때의 진단은 결국 Envoy listener에서 (a)transport_socket의 TLS context(PeerAuth), (b)jwt_authn filter(RequestAuth), (c)rbac filter(AuthorizationPolicy)가 실제 주입됐는지 보는 것 — config가 xDS로 어떻게 내려가 진단하는지는 [xDS 계층과 진단](xds__src-xds-layers-and-diagnosis.html) 참조.
- **STRICT 전환 시 telemetry/probe 경로 누락**: STRICT + 첫 ALLOW를 동시에 도입하면 health/readiness probe(15021/15020 경유는 예외지만 app 직접 probe는 아님)나 Prometheus scrape(15090) 트래픽이 ALLOW에 빠져 한꺼번에 막힐 수 있다. 첫 ALLOW에 운영 경로를 포함했는지 반드시 확인.