AI 에이전트 토큰 오남용 방어: OAuth 2.1 PKCE + RFC 8707 Resource Indicators 실전 가이드
Claude Desktop이 이메일 서버에서 정보를 꺼내고, 캘린더를 업데이트하고, 슬랙으로 요약을 보내는 워크플로우를 상상해 보겠습니다. 멀티 홉 에이전트 체인은 생산성을 획기적으로 높여주지만, 동시에 기존 OAuth 보안 모델이 상정하지 않았던 새로운 공격 표면을 만들어냅니다. 서비스 A에서 탈취한 액세스 토큰이 아무런 저항 없이 서비스 B, C, D로 재사용된다면—에이전트 체인에 연결된 모든 서비스가 단 하나의 토큰 탈취로 동시에 노출됩니다.
이미 프로덕션 MCP 서버를 운영 중이거나 에이전트 기반 워크플로우를 구축 중이라면, 현재 발급된 토큰에 aud 클레임이 있는지, 서비스별로 다른 값을 가지는지 지금 바로 확인해보시길 권장합니다. OWASP 2026 에이전트 AI 보안 위협 Top 10은 토큰 오남용·Confused Deputy·권한 에스컬레이션을 독립 위협 범주로 분류했고, MCP 사양은 2025년 6월부터 RFC 8707 Resource Indicators를 공식 필수 요건으로 채택했습니다.
이 글에서는 RFC 8707 Resource Indicators와 OAuth 2.1 PKCE가 각각 방어하는 공격 레이어가 어떻게 다른지, 그리고 두 메커니즘을 결합해 멀티 홉 에이전트 체인에 audience-restricted 토큰을 발급·검증하는 방어 패턴을 실전 코드와 함께 설명합니다.
사전 지식: OAuth 2.0의 기본 인가 코드 플로우(Authorization Code Flow)를 알고 있다고 가정합니다.
grant_type,aud클레임, JWKS URI 같은 용어가 낯설다면, OAuth 2.0 기본 개념을 먼저 살펴보신 후 돌아오시면 좋습니다. 이미 PKCE에 익숙하다면 실전 적용 섹션으로 바로 이동하셔도 됩니다.
이 글에서 다루는 것: RFC 8707 + PKCE 방어 패턴 구현(Python, TypeScript), Keycloak 우회 패턴, 멀티 테넌트 토큰 격리, 실무 실수 목록
다루지 않는 것: OAuth 2.0 기초 개념, 인가 서버(AS) 구축, mTLS 기반 발신자 제한(Sender-Constrained) 토큰
핵심 개념
RFC 8707: 토큰에 "누구를 위한 것인지" 새기기
RFC 8707은 OAuth 2.0 확장 사양으로, 인가 요청과 토큰 요청에 resource 파라미터를 추가해 액세스 토큰의 의도된 수신자(audience) 를 명시하는 메커니즘입니다. 인가 서버는 이 정보를 기반으로 토큰의 aud 클레임을 해당 리소스 URI로 제한하여 발급합니다.
GET /authorize?
response_type=code
&client_id=agent-orchestrator
&resource=https://api.service-a.example.com
&scope=read:data
&code_challenge=BASE64URL(SHA256(verifier))
&code_challenge_method=S256발급된 토큰의 페이로드는 다음과 같습니다.
{
"iss": "https://auth.example.com",
"sub": "user-123",
"aud": "https://api.service-a.example.com",
"scope": "read:data",
"exp": 1713100000
}Audience Restriction(수신자 제한): 토큰에
aud클레임을 포함시켜, 해당 클레임에 명시된 서비스만 그 토큰을 수락하도록 강제하는 메커니즘입니다.service-a용 토큰을 탈취해도service-b에서는aud검증 실패로 즉시 거부됩니다.
에이전트 체인처럼 여러 서비스를 위임해 호출하는 컨텍스트에서는, sub 클레임이 최종 사용자를 나타내는 것만으로 부족할 수 있습니다. 이 경우 RFC 8693의 act(Actor) 클레임을 활용해 "사용자 A를 대신해 에이전트 B가 발급받은 토큰"이라는 위임 체인(delegation chain)을 명시적으로 표현하는 방향도 복잡한 에이전트 시나리오에서 고려해볼 만합니다.
OAuth 2.1 PKCE: 인가 코드 자체를 봉인하기
PKCE(Proof Key for Code Exchange, RFC 7636)는 OAuth 2.1에서 모든 클라이언트에 의무화된 메커니즘입니다. 브라우저, 모바일 앱, Claude Desktop 같은 공개 클라이언트는 client_secret을 안전하게 보관할 수 없기 때문에, PKCE 없이는 인가 코드를 탈취해 토큰으로 교환하는 공격이 가능합니다.
| 단계 | 파라미터 | 역할 |
|---|---|---|
| 클라이언트 생성 | code_verifier |
43~128자 크립토 랜덤 문자열 (원본 비밀) |
| 인가 요청 | code_challenge |
BASE64URL(SHA256(code_verifier)) (공개 해시) |
| 토큰 교환 | code_verifier 원문 전송 |
서버가 재계산하여 code_challenge와 대조 검증 |
S256 메서드만 사용하는 것을 권장합니다.
plain메서드는code_verifier가 네트워크에 그대로 노출되어 PKCE의 보안 이점을 상쇄합니다. 인가 서버에서plain메서드를 비활성화해두는 것이 좋습니다.
RFC 8707 + PKCE, 왜 함께 필요한가
두 메커니즘은 직교(orthogonal)하는 서로 다른 공격 레이어를 방어합니다. 하나가 다른 하나를 대체하지 않습니다.
| 메커니즘 | 방어하는 공격 | 방어 지점 |
|---|---|---|
| PKCE | 인가 코드 가로채기 → 토큰 교환 공격 | 인가 플로우 단계 |
| RFC 8707 | 발급된 토큰의 다른 서비스 재사용·리플레이 | 토큰 사용 단계 |
PKCE가 없으면 공격자는 인가 코드를 탈취해 토큰을 직접 발급받을 수 있습니다. RFC 8707이 없으면 정상 발급된 토큰이 에이전트 체인의 다른 서비스에 재사용될 수 있습니다. 두 메커니즘을 함께 적용할 때 인가 코드 탈취부터 토큰 재사용까지의 전 구간이 방어됩니다.
Confused Deputy와 Brokered Credentials
Confused Deputy 공격: 에이전트가 합법적인 자격증명을 보유한 상태에서, 악의적인 입력(예: 프롬프트 인젝션)에 의해 자신의 권한 밖의 행동을 수행하도록 유도되는 공격입니다. "신뢰받는 대리인(deputy)"이 신뢰를 오용해 공격자 대신 행동하는 형태에서 유래했습니다. RFC 8707의 audience restriction은 이 공격이 발생하더라도 피해 범위를 단일 서비스로 봉인하는 역할을 합니다.
Brokered Credentials 패턴: Auth0 Token Vault, Aembit처럼 에이전트가 토큰을 직접 보유·전달하지 않고, 브로커 서비스를 통해 단기 스코프 토큰을 위임받아 사용하는 아키텍처입니다. 에이전트가 침해되더라도 토큰 자체가 에이전트 메모리에 존재하지 않아 탈취 공격 표면을 최소화합니다.
멀티 홉 에이전트 체인의 주요 공격 벡터
| 공격 유형 | 설명 | 실제 위험 |
|---|---|---|
| 토큰 패스스루 | 서비스 A용 토큰을 에이전트가 서비스 B에 그대로 전달 | 모든 서비스에 동일 토큰으로 접근 가능 |
| Confused Deputy | 프롬프트 인젝션으로 합법적 에이전트가 권한 밖 행동 수행 | 에이전트 자체를 공격 도구로 전용 |
| 토큰 리플레이 | 특정 홉에서 탈취한 토큰을 다른 서비스에 불법 제출 | 수평 이동(Lateral Movement) |
| 권한 에스컬레이션 | 낮은 권한 요청이 에이전트 체인을 통해 관리자 권한으로 확대 | 의도치 않은 데이터 유출·삭제 |
전체 아키텍처 흐름
아래는 RFC 8707 + PKCE를 적용한 멀티 홉 에이전트 체인의 전체 흐름입니다. 코드 예시에 진입하기 전 큰 그림을 파악해 두시면 각 코드 블록의 위치가 명확해집니다.
┌──────────────────────────────────────────────────────────────────────┐
│ 사용자 → 오케스트레이터 에이전트 │
│ │
│ Step 1: 이메일 서비스용 토큰 요청 (PKCE + resource=email-uri) │
│ ┌──────────────────┐ 인가 요청 ┌───────────────────┐ │
│ │ 오케스트레이터 │ ────────────→ │ 인가 서버 (AS) │ │
│ │ (code_verifier) │ ←─────────── │ code_challenge │ │
│ │ │ 인가 코드 │ 검증 + aud 제한 │ │
│ │ │ ────────────→ │ 토큰 발급 │ │
│ │ │ ←─────────── │ │ │
│ └────────┬─────────┘ 이메일 전용 토큰 └───────────────────┘ │
│ │ {aud: "email-uri"} │
│ ↓ │
│ Step 2: 이메일 MCP 서버 호출 │
│ ┌──────────────────┐ Bearer 토큰 ┌───────────────────┐ │
│ │ 오케스트레이터 │ ────────────→ │ 이메일 MCP 서버 │ │
│ │ │ │ aud 검증: ✓ │ │
│ └──────────────────┘ └───────────────────┘ │
│ │
│ Step 3: 캘린더 서비스용 별도 토큰 요청 (resource=calendar-uri) │
│ ┌──────────────────┐ ┌───────────────────┐ │
│ │ 오케스트레이터 │ ────────────→ │ 인가 서버 (AS) │ │
│ │ (새 code_verifier) │ ←─────────── │ 캘린더 전용 토큰 │ │
│ └────────┬─────────┘ └───────────────────┘ │
│ │ {aud: "calendar-uri"} │
│ ↓ │
│ 이메일 토큰으로 캘린더 API 호출 시도 → aud mismatch → 즉시 거부됨 │
└──────────────────────────────────────────────────────────────────────┘실전 적용
어떤 예시를 봐야 할까요?
- 예시 1: MCP 오케스트레이터에서 여러 서브 에이전트를 순차 호출하는 경우 (Python, 기본 패턴)
- 예시 2: 멀티 테넌트 SaaS에서 테넌트 간 토큰 격리가 필요한 경우 (TypeScript)
- 예시 3: 인가 서버로 Keycloak을 사용 중이며 RFC 8707 네이티브 지원 없이 우회가 필요한 경우
예시 1: MCP 오케스트레이터 → 이메일·캘린더 서브 에이전트 체인
사용자가 Claude Desktop을 통해 이메일 MCP 서버와 캘린더 MCP 서버를 연속 호출하는 시나리오입니다. 아래는 Python(httpx 0.27+)으로 구현한 오케스트레이터의 토큰 요청 로직입니다.
import secrets
import hashlib
import base64
import httpx
def generate_pkce_pair() -> tuple[str, str]:
"""PKCE code_verifier와 code_challenge 쌍을 생성합니다."""
verifier = secrets.token_urlsafe(64) # 약 86자, S256 요건 충족
digest = hashlib.sha256(verifier.encode()).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
return verifier, challenge
async def get_resource_token(
auth_server: str,
client_id: str,
resource_uri: str, # RFC 8707 핵심: 각 서비스마다 별도 URI
scope: str,
) -> str:
verifier, challenge = generate_pkce_pair()
# 1단계: resource 파라미터와 code_challenge를 포함한 인가 요청
auth_params = {
"response_type": "code",
"client_id": client_id,
"resource": resource_uri,
"scope": scope,
"code_challenge": challenge,
"code_challenge_method": "S256", # plain 메서드는 사용하지 않음
"redirect_uri": "https://agent.example.com/callback",
}
# 에이전트 환경에서는 브라우저 리다이렉트 없이 콜백 처리가 필요합니다.
# Device Authorization Flow(RFC 8628) 또는 로컬 콜백 서버(127.0.0.1:PORT)
# 패턴을 사용하는 것을 권장합니다. 아래 함수는 해당 처리가 완료된 후
# 인가 코드를 반환하는 플레이스홀더입니다.
auth_code = await redirect_and_get_code(auth_server, auth_params)
# 2단계: code_verifier 원문으로 토큰 교환
async with httpx.AsyncClient() as client:
response = await client.post(
f"{auth_server}/token",
data={
"grant_type": "authorization_code",
"code": auth_code,
"client_id": client_id,
"resource": resource_uri, # 토큰 요청에도 반드시 포함 (RFC 8707)
"code_verifier": verifier,
"redirect_uri": "https://agent.example.com/callback",
},
)
response.raise_for_status() # HTTP 오류 시 예외 발생 (4xx, 5xx)
token_data = response.json()
# verifier는 함수 스코프 종료 시 자동 소멸
return token_data["access_token"]
# 오케스트레이터: 각 서비스마다 별도 토큰 요청
email_token = await get_resource_token(
auth_server="https://auth.example.com",
client_id="orchestrator-agent",
resource_uri="https://email.mcp.example.com",
scope="read:email",
)
calendar_token = await get_resource_token(
auth_server="https://auth.example.com",
client_id="orchestrator-agent",
resource_uri="https://calendar.mcp.example.com",
scope="write:events",
)
# email_token으로 calendar API 호출 시 → aud mismatch로 즉시 거부됩니다아래는 MCP 서버 측에서 수신된 토큰의 aud 클레임을 검증하는 미들웨어 예시입니다. (python-jose 3.x 기준)
from jose import jwt, JWTError
from fastapi import HTTPException, Request
EXPECTED_AUDIENCE = "https://email.mcp.example.com"
async def verify_token_audience(request: Request) -> dict:
"""aud 클레임 검증 — 리소스 서버 측 구현이 필수입니다."""
auth_header = request.headers.get("Authorization", "")
if not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing bearer token")
token = auth_header[7:]
try:
payload = jwt.decode(
token,
key=get_public_key(),
algorithms=["RS256"],
audience=EXPECTED_AUDIENCE, # aud 불일치 시 JWTError 발생
)
except JWTError as e:
raise HTTPException(status_code=401, detail=f"Token validation failed: {e}")
return payload에이전트 체인에서의 토큰 수명(TTL) 고려사항: 에이전트가 여러 서비스를 빠르게 연속 호출하는 패턴에서는 토큰 수명을 짧게(예: 5분) 유지하는 것이 탈취 시 공격 창을 최소화합니다. 다만 TTL이 짧아질수록 refresh 토큰 요청 빈도가 증가하고 인가 서버 부하가 높아지는 트레이드오프가 있습니다. 에이전트 체인의 평균 실행 시간을 측정한 후 적절한 TTL을 설정하는 방법을 권장합니다.
| 코드 포인트 | 설명 |
|---|---|
resource_uri |
각 서비스 호출마다 다른 URI를 지정해 토큰을 서비스별로 격리 |
code_challenge_method="S256" |
plain 대신 SHA-256 해시 방식 강제 |
response.raise_for_status() |
HTTP 오류 응답 시 token_data["access_token"] KeyError 방지 |
resource in token request |
인가 요청뿐 아니라 토큰 요청에도 반드시 포함 (RFC 8707) |
audience=EXPECTED_AUDIENCE |
리소스 서버 측 aud 검증 — 클라이언트가 올바른 resource를 보냈더라도 서버 검증이 없으면 무의미 |
예시 2: 멀티 테넌트 SaaS에서 테넌트 크로스오버 방지
멀티 테넌트 환경에서는 테넌트 A의 에이전트가 발급받은 토큰이 테넌트 B의 리소스에 접근하는 크로스오버 공격을 방지해야 합니다. RFC 8707은 resource URI에 테넌트 식별자를 포함하는 패턴을 명시적으로 권장합니다. (TypeScript, Node.js crypto 모듈 기준)
import { createHash, randomBytes } from "crypto";
interface AgentTokenRequest {
tenantId: string;
resourceType: "data" | "analytics" | "admin";
scope: string;
}
function buildResourceUri(tenantId: string, resourceType: string): string {
// RFC 8707 권장: 테넌트 식별자를 URI 경로에 포함
return `https://api.example.com/tenant/${tenantId}/${resourceType}`;
}
function generatePkcePair(): { verifier: string; challenge: string } {
const verifier = randomBytes(64).toString("base64url");
const challenge = createHash("sha256")
.update(verifier)
.digest("base64url");
return { verifier, challenge };
}
async function requestTenantScopedToken(
req: AgentTokenRequest,
authServerUrl: string,
clientId: string
): Promise<string> {
const resourceUri = buildResourceUri(req.tenantId, req.resourceType);
const { verifier, challenge } = generatePkcePair();
const authUrl = new URL(`${authServerUrl}/authorize`);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", clientId);
authUrl.searchParams.set("resource", resourceUri);
authUrl.searchParams.set("scope", req.scope);
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
// 에이전트 환경의 콜백 처리: Device Authorization Flow 또는
// 로컬 콜백 서버(127.0.0.1:PORT) 패턴을 사용하는 것을 권장합니다.
const code = await redirectAndGetCode(authUrl.toString());
const tokenResponse = await fetch(`${authServerUrl}/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: clientId,
resource: resourceUri,
code_verifier: verifier,
}),
});
if (!tokenResponse.ok) {
const errorBody = await tokenResponse.text();
throw new Error(`Token request failed (${tokenResponse.status}): ${errorBody}`);
}
const { access_token } = await tokenResponse.json();
// verifier는 이 블록 종료 후 GC 대상
return access_token;
}아래는 Express 리소스 서버의 aud 검증 미들웨어입니다. (express-jwt v8, jwks-rsa 3.x 기준)
import { expressjwt } from "express-jwt";
import jwksRsa from "jwks-rsa";
// JWKS 시크릿 헬퍼는 모듈 레벨에서 한 번만 생성해 공유합니다.
// cache: true 설정으로 매 요청마다 인가 서버에 JWKS 조회하는 것을 방지합니다.
const sharedJwksSecret = jwksRsa.expressJwtSecret({
jwksUri: "https://auth.example.com/.well-known/jwks.json",
cache: true,
cacheMaxEntries: 5,
cacheMaxAge: 600_000, // 10분
});
function createTenantAudienceValidator(tenantId: string, resourceType: string) {
const expectedAudience = `https://api.example.com/tenant/${tenantId}/${resourceType}`;
return expressjwt({
secret: sharedJwksSecret, // 공유 인스턴스 재사용 (성능)
audience: expectedAudience, // tenant-scoped aud 검증
algorithms: ["RS256"],
});
}
// 각 테넌트 라우터에 독립적인 audience 검증기 적용
app.use(
"/tenant/:tenantId/data",
(req, res, next) =>
createTenantAudienceValidator(req.params.tenantId, "data")(req, res, next)
);주의:
sharedJwksSecret을 모듈 레벨 상수로 선언하지 않고createTenantAudienceValidator내부에서 매번jwksRsa.expressJwtSecret을 호출하면, 요청마다 새로운 JWKS 클라이언트 인스턴스가 생성되어 캐싱이 무력화됩니다. 프로덕션 환경에서는 반드시 공유 인스턴스를 사용하는 것을 권장합니다.
예시 3: Keycloak에서 RFC 8707 우회 구현
Keycloak은 2026년 현재 RFC 8707의 resource 파라미터를 네이티브로 지원하지 않습니다. 이 한계를 우회하는 현실적인 두 가지 방법을 소개합니다.
방법 A: 대상 클라이언트별 oidc-audience-mapper 설정
Keycloak의 oidc-audience-mapper는 특정 클라이언트(서비스)를 대상으로 해당 클라이언트의 client_id를 aud 클레임에 추가하도록 구성할 수 있습니다. 동적 resource 파라미터 기반 매핑은 아니지만, 각 서비스별 클라이언트 설정으로 audience restriction 효과를 낼 수 있습니다.
{
"name": "email-service-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": {
"included.client.audience": "email-mcp-service",
"access.token.claim": "true",
"id.token.claim": "false"
}
}이 방식은 클라이언트 스코프 단위의 정적 aud 설정입니다. 서비스 수가 많아질수록 설정 항목이 늘어나는 단점이 있습니다.
방법 B: 커스텀 SPI(Service Provider Interface) 구현
요청마다 다른 resource 파라미터를 동적으로 aud에 반영하려면 Keycloak 커스텀 SPI를 작성해야 합니다. Keycloak의 ProtocolMapper 인터페이스를 구현해 요청 파라미터를 읽고 aud 클레임에 매핑하는 방식입니다.
Keycloak 사용 시 권장사항: RFC 8707 네이티브 지원이 필요하다면 Authlete 또는 Auth0를 검토해 보시길 권장합니다. 커스텀 SPI 유지보수 비용 대비 관리형 인가 서버 도입을 고려해볼 만한 상황입니다.
주의:
oidc-hardcoded-claim-mapper는 리터럴 문자열만 지원하며,"claim.value": "${resource}"형태로 요청 파라미터를 동적 참조하는 것은 지원되지 않습니다. 이 설정으로는 요청마다 다른resource값을aud에 매핑할 수 없어, 그대로 적용하면 동작하지 않습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| Lateral Movement 차단 | 한 서비스가 침해되어도 획득한 토큰으로 다른 서비스 접근이 불가능합니다 |
| Confused Deputy 방어 | 각 홉에 audience가 바인딩된 토큰이 재전달되면 즉시 인증 실패로 공격이 무력화됩니다 |
| 인가 코드 탈취 방어 | PKCE code_verifier 없이 인가 코드만으로는 토큰 교환이 불가능합니다 |
| 최소 권한 원칙 구현 | 서비스별 scope + resource 조합으로 세밀한 권한 부여가 가능합니다 |
| 감사 추적 강화 | 토큰에 의도된 수신자가 명시되어 이상 탐지와 로깅이 용이합니다 |
| MCP 사양 준수 | 2025년 6월 이후 MCP 필수 요건으로, 에이전트 생태계 호환성이 확보됩니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| IdP 지원 부족 | Keycloak은 RFC 8707 네이티브 미지원 | Audience Mapper(정적) 또는 커스텀 SPI(동적), 또는 Authlete/Auth0 도입 검토 |
| 구현 복잡성 증가 | 에이전트 체인의 각 홉마다 별도 토큰 요청 로직 필요 | 공통 get_resource_token() 유틸 함수로 캡슐화 |
| 토큰 수명 관리 | Resource-specific 토큰이 늘어남에 따라 refresh 관리 복잡성 증가 | Auth0 Token Vault, Aembit 같은 에이전트 전용 토큰 관리 솔루션 활용 |
| 다운그레이드 공격 | 서버가 PKCE를 지원하지만 강제하지 않으면 PKCE 없는 요청 허용 가능 | 인가 서버에서 PKCE 미포함 요청을 명시적으로 거부하도록 설정 |
code_verifier 저장 |
브라우저 환경에서 verifier를 안전하게 임시 보관해야 함 | sessionStorage 사용, 인가 코드 교환 완료 즉시 삭제 |
실무에서 가장 흔한 실수
- 리소스 서버에서
aud클레임을 검증하지 않는 경우: 클라이언트가 올바른resource파라미터를 보내더라도 리소스 서버에서aud를 검증하지 않으면 audience restriction이 완전히 무의미해집니다. JWT 라이브러리의audience옵션을 반드시 활성화해두는 것을 권장합니다.python-jose3.x,express-jwtv8,golang-jwtv5 모두audience파라미터를 기본 지원합니다. - 토큰 요청에
resource파라미터를 생략하는 경우: RFC 8707은 인가 요청(authorization request)과 토큰 요청(token request) 양쪽 모두에resource파라미터를 포함하도록 규정합니다. 인가 요청에만 포함하고 토큰 요청에서 생략하는 경우가 종종 발생합니다. VS Code GitHub Copilot 확장에서도 이 패턴의 버그(Issue #261364)가 발견된 바 있습니다. - 에이전트가 수신한 토큰을 업스트림 API에 그대로 전달하는 경우: MCP 사양은 이 "토큰 패스스루" 행위를 명시적으로 금지합니다. 각 업스트림 호출에는 해당 서비스 URI를
resource로 지정한 새 토큰이 필요합니다. 코드 리뷰 단계에서 토큰을 함수 인자로 전달하는 패턴을 자동 검출하는 린트 규칙 추가를 고려해보시면 좋습니다.
마치며
구현 복잡성은 분명히 증가합니다. 각 홉마다 별도 토큰을 요청하고, 리소스 서버마다 aud 검증을 추가하고, 인가 서버의 RFC 8707 지원 여부를 확인해야 합니다. 그러나 에이전트 체인의 침해 반경을 단일 서비스로 봉인하는 효과는 그 비용을 정당화합니다. RFC 8707은 토큰의 재사용 반경을 제한하고, PKCE는 토큰 발급 과정 자체를 보호합니다—두 메커니즘이 직교하는 방어층을 형성할 때 에이전트 체인 전 구간이 안전해집니다.
지금 운영 중인 에이전트 시스템이나 MCP 서버가 있다면, 아래 3단계로 보안 상태를 점검하고 개선을 시작해볼 수 있습니다.
- 현재 토큰의
aud클레임 확인: 테스트 환경이나 이미 만료된 토큰을jwt.io에서 디코딩해aud클레임의 존재 여부와 서비스별로 다른 값을 가지는지 확인해볼 수 있습니다. (프로덕션 유효 토큰을 외부 서비스에 붙여넣는 것은 보안 위험이 있으므로, 반드시 테스트 토큰이나 이미 만료된 토큰을 사용하는 것을 권장합니다.) 단일 광범위한aud값이나aud부재는 개선이 필요한 신호입니다. - 인가 서버에서 PKCE 강제 활성화: Auth0라면 애플리케이션 설정에서
Require PKCE를 활성화하고, Keycloak이라면 Client 설정에서PKCE Code Challenge Method를S256으로 설정할 수 있습니다. 이 설정 하나로 다운그레이드 공격을 원천 차단할 수 있습니다. - 리소스 서버 미들웨어에
audience검증 추가: 기존 JWT 검증 미들웨어에audience옵션 한 줄을 추가하는 것으로 서버 측 방어가 완성됩니다. 이미 JWT 검증을 수행 중이라면 사용 중인 라이브러리 문서에서audience파라미터만 찾아 추가하면 됩니다.
적용 후 자가진단 체크리스트
- 현재 토큰에 서비스별로 다른
aud값이 포함되어 있나요? - 인가 서버에서
plainPKCE 메서드가 비활성화되어 있나요? - 토큰 요청(
/token엔드포인트)에도resource파라미터가 포함되어 있나요? - 리소스 서버의 JWT 검증 코드에
audience옵션이 활성화되어 있나요? - 에이전트 코드가 수신한 토큰을 다른 서비스에 그대로 전달하지 않나요?
다음 글: OPA(Open Policy Agent)와 Cedar를 활용해 멀티 에이전트 시스템에 Policy-as-Code 기반 세밀한 권한 제어를 구현하는 방법
참고 자료
- RFC 8707: Resource Indicators for OAuth 2.0 | IETF
- RFC 7636: Proof Key for Code Exchange | IETF Datatracker
- Authorization - Model Context Protocol 공식 사양 | MCP
- MCP, OAuth 2.1, PKCE, and the Future of AI Authorization | Aembit
- OAuth 2.0 resource indicators (RFC 8707) explained | Scalekit
- Model Context Protocol Spec Updates - All About Auth | Auth0
- Protecting MCP Server with OAuth 2.1: A Practical Guide Using Go and Keycloak | Medium
- MCP Server Security: 7 OAuth 2.1 Best Practices | Ekamoira
- OWASP Top 10 for Agentic AI Security Risks 2026 | Startup Defense
- OAuth 2.1 현황 | oauth.net
- RFC 8707 Resource Indicators - Keycloak Issue #14355 | GitHub