Defense Against AI Agent Token Misuse: A Practical Guide to OAuth 2.1 PKCE + RFC 8707 Resource Indicators
Let's imagine a workflow where Claude Desktop retrieves information from an email server, updates a calendar, and sends a summary to Slack. While multi-hop agent chains dramatically increase productivity, they simultaneously create a new attack surface that existing OAuth security models did not anticipate. If an access token stolen from Service A is reused by Services B, C, and D without resistance—all services connected to the agent chain are simultaneously exposed by a single token theft.
If you are already running a production MCP server or building an agent-based workflow, we recommend that you check right now whether the currently issued tokens contain the aud claim and whether they have different values depending on the service. The OWASP 2026 Top 10 Agent AI Security Threats classified Token Misuse, Confused Deputy, and Privilege Escalation as independent threat categories, and the MCP specification adopted RFC 8707 Resource Indicators as an official requirement starting June 2025.
This article explains how the attack layers defended by RFC 8707 Resource Indicators and OAuth 2.1 PKCE differ, and describes a defense pattern that combines the two mechanisms to issue and validate audience-restricted tokens on a multi-hop agent chain, accompanied by practical code.
Prerequisites: It is assumed that you are familiar with the basic Authorization Code Flow of OAuth 2.0. If terms such as grant_type, aud claims, and JWKS URIs are unfamiliar, it is recommended that you review the basic concepts of OAuth 2.0 first before returning. If you are already familiar with PKCE, you may skip directly to the Practical Application section.
What this article covers: RFC 8707 + PKCE defense pattern implementation (Python, TypeScript), Keycloak bypass patterns, multi-tenant token isolation, list of practical mistakes What is not covered: OAuth 2.0 basic concepts, setting up an authorization server (AS), mTLS-based sender-constrained tokens
Key Concepts
RFC 8707: Engraving "Who It Is" on Tokens
RFC 8707 is an OAuth 2.0 extension specification that provides a mechanism to specify the intended audience of an access token by adding the resource parameter to authorization and token requests. Based on this information, the authorization server issues the token with the aud claim restricted to the corresponding resource 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=S256The payload of the issued token is as follows.
{
"iss": "https://auth.example.com",
"sub": "user-123",
"aud": "https://api.service-a.example.com",
"scope": "read:data",
"exp": 1713100000
}Audience Restriction: A mechanism that includes the aud claim in the token to force only the services specified in that claim to accept the token. Even if a token for service-a is stolen, it will be immediately rejected by service-b due to a aud verification failure.
In contexts where multiple services are delegated and called, such as in an agent chain, the sub claim may not be sufficient to represent only the end user. In this case, in complex agent scenarios, it is worth considering using the act(Actor) claim from RFC 8693 to explicitly represent the delegation chain as "tokens issued to Agent B on behalf of User A."
OAuth 2.1 PKCE: Sealing the Authorization Code Itself
PKCE (Proof Key for Code Exchange, RFC 7636) is a mechanism mandatory for all clients in OAuth 2.1. Since public clients such as browsers, mobile apps, and Claude Desktop cannot securely store client_secret, without PKCE, attacks are possible to steal authorization codes and exchange them for tokens.
| Step | Parameter | Role |
|---|---|---|
| Create Client | code_verifier |
43~128 character crypto random string (original secret) |
| Authorization Request | code_challenge |
BASE64URL(SHA256(code_verifier)) (Public Hash) |
| Token Exchange | Transmit code_verifier Original Text |
Server recalculates and verifies against code_challenge |
It is recommended to use only the S256 method. The plain method offsets the security benefits of PKCE by leaving code_verifier exposed to the network. It is recommended to disable the plain method on the authorization server.
RFC 8707 + PKCE, Why Are They Needed Together?
The two mechanisms defend against orthogonal different attack layers. One does not replace the other.
| Mechanism | Defensive Attack | Defense Point |
|---|---|---|
| PKCE | Authorization Code Interception → Token Exchange Attack | Authorization Flow Phase |
| RFC 8707 | Reuse/Replay of Issued Tokens in Other Services | Token Usage Steps |
Without PKCE, an attacker can steal the authorization code and directly issue tokens. Without RFC 8707, legitimately issued tokens can be reused by other services on the agent chain. When both mechanisms are applied together, the entire process from the theft of the authorization code to token reuse is defended against.
Confused Deputy와 Brokered Credentials
Confused Deputy Attack: This is an attack in which an agent, while possessing legitimate credentials, is induced by malicious input (e.g., prompt injection) to perform actions outside its authority. It originates from a situation where a "trusted deputy" abuses trust to act on behalf of an attacker. RFC 8707's audience restriction serves to seal the scope of damage to a single service even if this attack occurs.
Brokered Credentials Pattern: This is an architecture where agents, like Auth0 Token Vault and Aembit, do not directly hold or transfer tokens, but instead receive and use short-scoped tokens delegated through a broker service. Even if an agent is compromised, the token itself does not exist in the agent's memory, minimizing the attack surface for theft.
Major Attack Vectors of Multi-Hop Agent Chains
| Attack Type | Description | Actual Risk |
|---|---|---|
| Token Passthrough | Agent passes token for Service A to Service B as is | Access to all services is possible with the same token |
| Confused Deputy | Legitimate agent performs unauthorized actions via prompt injection | Dedicated agent itself as an attack tool |
| Token Replay | Illegally submitting tokens stolen from a specific hop to another service | Lateral Movement |
| Privilege Escalation | Low-privilege requests escalate to administrator privileges through the agent chain | Unintended data leakage/deletion |
Overall Architecture Flow
Below is the overall flow of a multi-hop agent chain applying RFC 8707 + PKCE. If you grasp the big picture before entering the code examples, the location of each code block will become clear.
┌──────────────────────────────────────────────────────────────────────┐
│ 사용자 → 오케스트레이터 에이전트 │
│ │
│ 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 → 즉시 거부됨 │
└──────────────────────────────────────────────────────────────────────┘Practical Application
Which example should I look at?
- Example 1: Sequentially invoking multiple sub-agents in an MCP orchestrator (Python, basic pattern)
- Example 2: When Token Isolation Between Tenants Is Needed in Multi-Tenant SaaS (TypeScript)
- Example 3: When using Keycloak as an authorization server and bypassing is required without native RFC 8707 support
Example 1: MCP Orchestrator → Email/Calendar Sub-agent Chain
This is a scenario where a user consecutively calls the email MCP server and the calendar MCP server through Claude Desktop. Below is the orchestrator's token request logic implemented in 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로 즉시 거부됩니다Below is an example of middleware that validates the aud claim of a received token on the MCP server side. (Based on 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 payloadConsiderations for Token Lifetime (TTL) in Agent Chains: In patterns where agents make rapid, consecutive calls to multiple services, maintaining a short token lifetime (e.g., 5 minutes) minimizes the attack window in the event of theft. However, there is a trade-off where a shorter TTL leads to an increased frequency of refresh token requests and higher load on the authorization server. It is recommended to set an appropriate TTL after measuring the average execution time of the agent chain.
| Code Point | Description |
|---|---|
resource_uri |
Isolate tokens by service by specifying a different URI for each service call |
code_challenge_method="S256" |
plain Force SHA-256 hash method instead |
response.raise_for_status() |
Prevent token_data["access_token"] KeyError on HTTP error response |
resource in token request |
Must be included in token requests as well as authorization requests (RFC 8707) |
audience=EXPECTED_AUDIENCE |
Resource Server-side aud Validation — Even if the client sends the correct resource, it is meaningless without server validation |
Example 2: Preventing Tenant Crossover in Multi-Tenant SaaS
In a multi-tenant environment, crossover attacks must be prevented where a token issued to an agent of Tenant A accesses resources of Tenant B. RFC 8707 explicitly recommends a pattern of including the tenant identifier in resource URIs. (Based on TypeScript and Node.js crypto module)
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;
}Below is the aud validation middleware for Express Resource Server. (Based on 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)
);Caution: If you do not declare sharedJwksSecret as a module-level constant and call jwksRsa.expressJwtSecret inside createTenantAudienceValidator every time, a new JWKS client instance is created for every request, rendering caching ineffective. It is strongly recommended to use a shared instance in production environments.
Example 3: Implementing RFC 8707 Bypass in Keycloak
As of 2026, Keycloak does not natively support the resource parameter of RFC 8707. We introduce two practical methods to bypass this limitation.
Method A: oidc-audience-mapper Configuration per Target Client
Keycloak's oidc-audience-mapper can be configured to target specific clients (services) and add that client's client_id to the aud claims. Although it is not a dynamic resource parameter-based mapping, it can achieve an audience restriction effect with client settings per service.
{
"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"
}
}This method is a static aud configuration at the client scope level. It has the disadvantage that the number of configuration items increases as the number of services increases.
Method B: Implementing a Custom SPI (Service Provider Interface)
To dynamically reflect resource parameters, which differ for each request, into aud, you must write a custom Keycloak SPI. This involves implementing Keycloak's ProtocolMapper interface to read request parameters and map them to aud claims.
Recommendations for Keycloak: If you require native RFC 8707 support, we recommend reviewing Authlete or Auth0. It is worth considering adopting a managed authorization server relative to the maintenance costs of a custom SPI.
Note: oidc-hardcoded-claim-mapper supports only literal strings, and dynamically referencing request parameters in the form of "claim.value": "${resource}" is not supported. With this setting, you cannot map different resource values per request to aud, so it will not work if applied as is.
Pros and Cons Analysis
Advantages
| Item | Content |
|---|---|
| Lateral Movement Blocked | Even if one service is compromised, access to other services is not possible with the acquired token |
| Confused Deputy Defense | The attack is neutralized by an immediate authentication failure when the token with the audience bound is re-delivered at each hop |
| Enforcement Code Theft Defense | Token exchange is impossible with the authorization code alone without PKCE code_verifier |
| Implementation of the Principle of Least Privilege | Granular permission granting is possible using service-specific combinations of scope + resource |
| Enhanced Audit Tracking | Intended recipients are specified in the token, facilitating anomaly detection and logging |
| MCP Specification Compliance | As a mandatory MCP requirement from June 2025, agent ecosystem compatibility is ensured |
Disadvantages and Precautions
| Item | Content | Response Plan |
|---|---|---|
| Lack of IdP Support | Keycloak Does Not Natively Support RFC 8707 | Consider Adopting Audience Mapper (Static), Custom SPI (Dynamic), or Authlete/Auth0 |
| Increased Implementation Complexity | Separate token request logic required for each hop in the agent chain | Encapsulated in a common get_resource_token() utility function |
| Token Lifecycle Management | Increased refresh management complexity as resource-specific tokens grow | Utilize agent-specific token management solutions such as Auth0 Token Vault and Aembit |
| Downgrade Attack | If the server supports PKCE but does not enforce it, requests without PKCE can be allowed | Configure the authorization server to explicitly deny requests without PKCE |
code_verifier Store |
The verifier must be safely stored temporarily in the browser environment | sessionStorage Delete immediately after use and authorization code exchange are complete |
The Most Common Mistakes in Practice
- If the resource server does not validate the
audclaim: Even if the client sends the correctresourceparameter, the audience restriction becomes completely meaningless if the resource server does not validateaud. It is strongly recommended to enable theaudienceoption of the JWT library.python-jose3.x,express-jwtv8, andgolang-jwtv5 all natively support theaudienceparameter. - Omitting the
resourceparameter in a token request: RFC 8707 mandates that theresourceparameter be included in both the authorization request and the token request. It is common for it to be included only in the authorization request and omitted from the token request. A bug (Issue #261364) related to this pattern has also been found in the VS Code GitHub Copilot extension. - When an agent passes the received token directly to an upstream API: The MCP specification explicitly prohibits this "token passthrough" behavior. Each upstream call requires a new token with the corresponding service URI set to
resource. You should consider adding lint rules to automatically detect patterns of passing tokens as function arguments during the code review phase.
In Conclusion
Implementation complexity clearly increases. You must request a separate token for each hop, add aud validation to every resource server, and verify RFC 8707 support for the authorization server. However, the effect of sealing the agent chain's compromise radius into a single service justifies this cost. RFC 8707 limits the radius of token reuse, and PKCE protects the token issuance process itself—when the two mechanisms form an orthogonal layer of defense, the entire agent chain becomes secure.
If you have an agent system or MCP server currently in operation, you can check its security status and start making improvements by following the 3 steps below.
- Check the
audclaims of the current token: You can decode a test environment or an expired token injwt.ioto check for the existence ofaudclaims and whether they have different values depending on the service. (Since pasting a production-valid token into an external service poses a security risk, it is strongly recommended to use a test token or an expired token.) A single broadaudvalue or the absence ofaudis a signal that improvement is needed. - Force PKCE activation on the authorization server: If it is Auth0, enable
Require PKCEin the application settings, and if it is Keycloak, you can setPKCE Code Challenge MethodtoS256in the client settings. With this single setting, you can block downgrade attacks at the source. - Add
audiencevalidation to resource server middleware: Server-side protection is completed by adding a single line ofaudienceoptions to the existing JWT validation middleware. If you are already performing JWT validation, you only need to find and add theaudienceparameter from the documentation of the library you are using.
Self-Assessment Checklist After Application
- Does the current token contain service-specific
audvalues? - Is the
plainPKCE method disabled on the authorized server? - Does the
resourceparameter also appear in the token request (/tokenendpoint)? - Is the
audienceoption enabled in the resource server's JWT validation code? - Does the agent code not pass the received token to other services as is?
Next Post: How to Implement Policy-as-Code Based Granular Authority Control in Multi-Agent Systems Using OPA (Open Policy Agent) and Cedar
Reference Materials
- RFC 8707: Resource Indicators for OAuth 2.0 | IETF
- RFC 7636: Proof Key for Code Exchange | IETF Datatracker
- Authorization - Model Context Protocol Official Specification | 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 Status | oauth.net
- RFC 8707 Resource Indicators - Keycloak Issue #14355 | GitHub