How to Design an MCP Gateway as a Zero Trust PEP — Implementing Least Privilege with OAuth 2.1, OPA, and Epimeral Tokens
Prerequisites for this article: This article is intended for those with experience in JWT authentication and REST API development. It will be easier to read if you are familiar with the basic OAuth 2.0 flow.
The era has arrived where AI agents query databases, modify files, and call external APIs. If you consider what actually happens, the answer is already clear. In mid-2025, a Supabase Cursor agent running with Privileged service-role privileges executed SQL commands inserted into support tickets, resulting in the leakage of sensitive integration tokens. This was a direct consequence of an agent operating without the principle of least privilege. An analysis of over 2,500 real-world MCP plugins in the arXiv 2504.08623 study revealed command injection flaws in 43% of the tested implementations and unlimited URL patching in 30%.
Model Context Protocol (MCP) has established itself as the de facto standard for AI agent infrastructure, with adoption by Claude in 2025, OpenAI (officially announcing full support by Sam Altman in March 2026), and Google Gemini. As the ecosystem expands, delaying security design results in a correspondingly larger number of agents and systems operating in a vulnerable state.
In this article, we examine how to build a Zero Trust Least Privilege architecture step-by-step by utilizing an MCP Gateway as a Policy Enforcement Point (PEP). The following is the core flow of the architecture that will be completed through this article.
[에이전트] → [MCP 게이트웨이 (PEP)]
↓ JWT 검증 → OPA 정책 평가 → 에피머럴 토큰 발급
→ [MCP Server A: DB]
→ [MCP Server B: File]
→ [MCP Server C: API]After reading this, you will be able to design your own gateway that "forces agents to use only the tools they need, only when needed, and within the minimum scope."
Key Concepts
The Three Layers of MCP Architecture — Why Are Host and Client Separated?
MCP (Model Context Protocol) is a protocol released as open source by Anthropic in 2024. It serves to connect AI models with external systems (databases, APIs, file systems, etc.) in a standardized manner and is often likened to a "USB-C port for AI."
| Hierarchy | Role | Example |
|---|---|---|
| MCP Host | Applications that interact directly with the user | Claude Desktop, VS Code, Cursor |
| MCP Client | Maintains a 1:1 session with the server within the host | Embedded protocol client |
| MCP Server | A lightweight service that exposes specific tools or data | DB connectors, file servers, API wrappers |
It is important to understand why the Host and Client are separated. The Host is responsible for the user interface and the entire application lifecycle, while the Client maintains independent JSON-RPC stateful sessions with each MCP server. If Claude Desktop connects simultaneously to three locations—a DB server, a File server, and an API server—the structure involves three Client instances. This 1:1 session structure becomes a key challenge in designing gateway horizontal scaling (the stateful session issue is discussed in detail in the pros and cons analysis).
The protocol provides context to the LLM in three primitive types: Tools (executable functions), Resources (readable data), and Prompts (reusable templates).
Why an MCP Gateway Is Needed
If a client directly connects to 10 MCP servers, authentication methods differ for each server, audit logs are scattered, and all 10 locations must be modified when policies are changed. The MCP Gateway is an intermediate layer situated between the AI client and multiple MCP servers, enforcing all traffic to pass through a single entry point and centrally handling routing, authentication, policy enforcement, and observability.
Zero Trust: The Three Principles of Least Authority
Definition of Zero Trust: "Never Trust, Always Verify." It is a security model that does not grant trust simply because a component is located within the network boundary.
When applying Zero Trust to AI agents, it is embodied in three principles.
- Identity-based Verification: All agents, users, and services must prove their identity for every request.
- Least Privilege: Grants only the minimum privileges required for the current task and applies time limits if possible.
- Micro-segmentation: Narrows the range of tools and data accessible to the agent to the individual task level. The dynamic tool filtering in Practical Application Example 4 is a pattern that implements this principle.
OAuth 2.1 and MCP Authentication Standards (Finalized in June 2025)
In June 2025, the official MCP specification officially classified the MCP server as an OAuth 2.0 Resource Server.
| Specifications | Content |
|---|---|
| RFC 8707 (Resource Indicators) | The client sends a dynamic server URL in the resource parameter. Since this token is valid only on the corresponding MCP server, the token cannot be reused on another server even if the agent is compromised. |
| RFC 8693 (Token Exchange) | Delegation Pattern for Exchanging Broad-Scope Tokens for Narrow-Scope Tokens |
| RFC 9728 (Protected Resource Metadata) | MCP Server Automatically Advertises Authorization Server Location |
Definition of Ephemeral Token: A one-time token valid for only a single task or a short period of time (usually 60 to 300 seconds). It is a key means of limiting the scope of damage to specific tasks and specific periods of time even if an agent is compromised.
Practical Application
Examples 1 through 4 are layers for progressively configuring a single gateway. The policy file from Example 2 is placed on top of the PEP middleware from Example 1, the token exchange logic from Example 3 is integrated into the authentication layer, and the dynamic filtering from Example 4 controls tool exposure.
[요청 진입]
↓
[예시 1] JWT 검증 + OPA 정책 평가 + 에피머럴 토큰 발급 ← 예시 2의 Rego 파일이 평가됨
↓
[예시 3] RFC 8693 토큰 교환 → 서버별 좁은 스코프 토큰 획득
↓
[예시 4] 사용자 역할 기반 도구 목록 필터링 (마이크로 세그멘테이션)
↓
[MCP Server로 프록시]Example 1: Authentication and Authorization Design — PEP Middleware and Fail-Closed Implementation
All tool call requests pass through the gateway, query the OPA for a policy, and determine whether to allow or deny. A core principle of Zero Trust is that OPA query failures must be treated as a default fail-closed.
First, let's look at verifyAgentJWT as an actual implementation using the jsonwebtoken library.
// gateway/middleware/auth.ts
import jwt from 'jsonwebtoken';
interface AgentIdentity {
agentId: string;
role: string;
userId: string;
}
async function verifyAgentJWT(
authHeader: string | undefined,
): Promise<AgentIdentity | null> {
if (!authHeader?.startsWith('Bearer ')) {
return null;
}
const token = authHeader.slice(7);
const publicKey = process.env.AGENT_JWT_PUBLIC_KEY;
if (!publicKey) {
// 환경 변수 누락 시에도 Fail-Closed
console.error('AGENT_JWT_PUBLIC_KEY not configured');
return null;
}
try {
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: process.env.JWT_ISSUER,
}) as AgentIdentity & jwt.JwtPayload;
return {
agentId: decoded.agentId,
role: decoded.role,
userId: decoded.userId,
};
} catch {
// 토큰 위조·만료·서명 불일치 모두 null 반환 → 401
return null;
}
}Next are OPA query functions and PEP middleware.
// gateway/middleware/policy-enforcement.ts
import { Request, Response, NextFunction } from 'express';
interface MCPToolCallRequest {
agentId: string;
userId: string;
toolName: string;
params: Record<string, unknown>;
}
interface PolicyDecision {
allowed: boolean;
reason?: string;
}
async function queryOPA(input: {
agent: AgentIdentity;
user: string;
tool: string;
params: Record<string, unknown>;
timestamp: number;
}): Promise<PolicyDecision> {
try {
const response = await fetch(
`${process.env.OPA_URL}/v1/data/mcp/tool_access`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input }),
},
);
if (!response.ok) {
// OPA 서버 오류 시 Fail-Closed: 정책 엔진 불가 → 기본 거부
console.error(`OPA returned ${response.status}`);
return { allowed: false, reason: 'policy engine unavailable' };
}
const data = await response.json();
return {
allowed: data.result?.allow ?? false,
reason: data.result?.deny_reason,
};
} catch (err) {
// 네트워크 오류도 Fail-Closed
console.error('OPA connection error:', err);
return { allowed: false, reason: 'policy engine unreachable' };
}
}
async function enforceMCPPolicy(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const toolCall = req.body as MCPToolCallRequest;
// 1단계: 에이전트 신원 검증
const agentIdentity = await verifyAgentJWT(req.headers.authorization);
if (!agentIdentity) {
res.status(401).json({ error: 'Agent identity verification failed' });
return;
}
// 2단계: OPA 정책 질의 (Fail-Closed 포함)
const decision = await queryOPA({
agent: agentIdentity,
user: toolCall.userId,
tool: toolCall.toolName,
params: toolCall.params,
timestamp: Date.now(),
});
if (!decision.allowed) {
await auditLog({
event: 'tool_call_denied',
agentId: toolCall.agentId,
tool: toolCall.toolName,
reason: decision.reason,
});
res.status(403).json({ error: `Access denied: ${decision.reason}` });
return;
}
// 3단계: 에피머럴 토큰 발급 (단일 작업용, 60초 만료)
req.ephemeralToken = await mintEphemeralToken({
scope: `tool:${toolCall.toolName}`,
subject: toolCall.agentId,
expiresIn: '60s',
});
next();
}| Code Location | Role |
|---|---|
verifyAgentJWT |
RS256 signature verification. Returns null for forgery, expiration, and configuration errors. |
queryOPA |
Fail-Closed: Handled as default rejection even in case of OPA failure |
mintEphemeralToken |
Issue single-operation token only if authorized, expires after 60 seconds |
auditLog |
Structured Logging of Denial Events — The Basis of SOC 2·HIPAA Audit Trails |
Example 2: Policy Design — Expressing Least Privilege with OPA Rego
The queryOPA() of the preceding PEP middleware evaluates this policy file. Defining the policy as code (Policy-as-Code) enables Git version control and code review.
# policies/mcp/tool_access.rego
package mcp.tool_access
import future.keywords.if
import future.keywords.in
default allow = false
# 읽기 전용 역할은 읽기 도구만 허용
allow if {
input.agent.role == "readonly"
input.tool in readonly_tools
}
# 쓰기 역할은 읽기 + 쓰기 도구 허용 (관리 도구는 제외)
allow if {
input.agent.role == "readwrite"
input.tool in allowed_write_tools
not is_admin_tool(input.tool)
}
# 시간 기반 제한: 업무 시간(KST 09:00-18:00)에만 쓰기 허용
allow if {
input.agent.role == "readwrite"
business_hours_kst
}
readonly_tools := {
"database_query",
"file_read",
"metrics_fetch",
}
allowed_write_tools := readonly_tools | {
"file_write",
"database_insert",
}
is_admin_tool(tool) if {
tool in {"database_drop", "user_delete", "config_override"}
}
business_hours_kst if {
# time.now_ns()는 UTC 기준 나노초를 반환합니다
# KST(UTC+9) 변환: UTC 시(hour)에 9를 더해 24로 나눕니다
utc_hour := time.clock(time.now_ns())[0]
kst_hour := (utc_hour + 9) % 24
kst_hour >= 9
kst_hour < 18
}
# 거부 사유를 함께 반환
deny_reason := "readonly role cannot use write tools" if {
input.agent.role == "readonly"
input.tool in allowed_write_tools
}
deny_reason := "admin tools require explicit approval" if {
is_admin_tool(input.tool)
}
deny_reason := "write tools only available during business hours (KST 09-18)" if {
input.agent.role == "readwrite"
not business_hours_kst
}Time Zone Note: time.now_ns() returns UTC. If you omit the KST (UTC+9) conversion when creating a policy based on Korean business hours, utc_hour will remain 0 even at the actual 09:00 KST, causing the policy to malfunction silently. The (utc_hour + 9) % 24 conversion is mandatory.
Example 3: Token Management — RFC 8693 Narrowing Scope
After identity verification by the aforementioned PEP middleware, the authentication layer exchanges broad access tokens for tokens valid only for specific MCP servers and specific tasks. By narrowing the token scope, even if an agent is compromised, the scope of damage is limited to those specific servers and tools.
The reason Python is used in this example is that major authentication server SDKs, such as those from Red Hat and Auth0, primarily provide Python examples, making Python commonly used for authentication layer integration. Separating the gateway's HTTP layer (TypeScript) from the authentication and secret layer (Python) is a realistic choice that reflects the differences in library maturity between the ecosystems.
# gateway/auth/token_exchange.py
import os
import httpx
from dataclasses import dataclass
@dataclass
class NarrowedToken:
access_token: str
scope: str
expires_in: int
target_server: str
async def exchange_for_narrow_token(
broad_token: str,
target_mcp_server: str,
required_tool: str,
) -> NarrowedToken:
"""
RFC 8693 Token Exchange: 광범위한 토큰 → 특정 서버·도구 전용 토큰
RFC 8707: resource 파라미터에 동적 서버 URL을 담아 전송하므로,
이 토큰은 target_mcp_server 외 다른 서버에서 사용될 수 없습니다
resource 값을 하드코딩하면 이 바인딩 보장이 깨지므로 주의하세요
"""
auth_server_url = os.environ["AUTH_SERVER_URL"]
async with httpx.AsyncClient() as client:
try:
response = await client.post(
url=f"{auth_server_url}/oauth/token",
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": broad_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
# RFC 8707: 동적 서버 URL 바인딩 — 절대 하드코딩 금지
"resource": f"https://mcp.example.com/servers/{target_mcp_server}",
"scope": f"tool:{required_tool}",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
},
timeout=5.0,
)
response.raise_for_status()
except httpx.HTTPStatusError as e:
# 토큰 교환 실패도 Fail-Closed
raise RuntimeError(
f"Token exchange failed: {e.response.status_code}"
) from e
except httpx.RequestError as e:
raise RuntimeError(f"Auth server unreachable: {e}") from e
data = response.json()
return NarrowedToken(
access_token=data["access_token"],
scope=data["scope"],
expires_in=data["expires_in"], # 보통 60~300초
target_server=target_mcp_server,
)
async def get_secret_for_legacy_server(
server_name: str,
vault_token: str, # 전역 변수 대신 함수 파라미터로 전달 — 보안 필수
) -> str:
"""
OAuth2를 지원하지 않는 레거시 MCP 서버용:
HashiCorp Vault에서 동적으로 단기 시크릿을 검색합니다
"""
vault_url = os.environ["VAULT_URL"]
async with httpx.AsyncClient() as client:
try:
response = await client.get(
url=f"{vault_url}/v1/secret/mcp/{server_name}",
headers={"X-Vault-Token": vault_token},
timeout=3.0,
)
response.raise_for_status()
except httpx.HTTPStatusError as e:
raise RuntimeError(
f"Vault lookup failed: {e.response.status_code}"
) from e
return response.json()["data"]["api_key"]Example 4: Dynamic Tool Filtering — Role-Based Micro-Segmentation
On top of the preceding PEP middleware and policy layers, the agent dynamically determines the list of authorized tools when initiating a session. This is how the micro-segmentation principle of Zero Trust is implemented at the tool layer. Since this layer is the gateway's main HTTP layer (the same Express middleware stack as in Example 1), TypeScript is used.
// gateway/tools/dynamic-filter.ts
interface UserContext {
userId: string;
roles: string[];
department: string;
}
interface MCPTool {
name: string;
description: string;
requiredRoles: string[];
sensitivityLevel: 'low' | 'medium' | 'high';
}
const ALL_MCP_TOOLS: MCPTool[] = [
{
name: 'database_query',
description: 'Read-only SQL query',
requiredRoles: ['analyst', 'engineer'],
sensitivityLevel: 'low',
},
{
name: 'database_insert',
description: 'Insert rows into database',
requiredRoles: ['engineer'],
sensitivityLevel: 'medium',
},
{
name: 'payment_process',
description: 'Process payment transaction',
requiredRoles: ['payment_admin'],
sensitivityLevel: 'high',
},
{
name: 'user_delete',
description: 'Delete user account',
requiredRoles: ['super_admin'],
sensitivityLevel: 'high',
},
];
async function checkHighSensitivityApproval(
toolName: string,
userContext: UserContext,
): Promise<boolean> {
// 고위험 도구별 허용 부서 목록 (마이크로 세그멘테이션)
const sensitiveToolDepartments: Record<string, string[]> = {
payment_process: ['finance', 'treasury'],
user_delete: ['platform-ops'],
};
const allowedDepts = sensitiveToolDepartments[toolName];
if (allowedDepts && !allowedDepts.includes(userContext.department)) {
return false;
}
// 고위험 도구는 KST 업무 시간(09:00-18:00) 외 차단
const kstHour = (new Date().getUTCHours() + 9) % 24;
if (kstHour < 9 || kstHour >= 18) {
return false;
}
return true;
}
async function getPermittedTools(userContext: UserContext): Promise<MCPTool[]> {
// 세션 시작 시 기본 활성 도구 = 없음 (Zero Trust 원칙)
const permittedTools: MCPTool[] = [];
for (const tool of ALL_MCP_TOOLS) {
const hasRequiredRole = tool.requiredRoles.some((role) =>
userContext.roles.includes(role),
);
if (!hasRequiredRole) continue;
if (tool.sensitivityLevel === 'high') {
const approved = await checkHighSensitivityApproval(tool.name, userContext);
if (!approved) continue;
}
permittedTools.push(tool);
}
// 허가된 도구만 반환 — 존재하지 않는 도구는 에이전트에 보이지 않음
return permittedTools;
}Security Principles of Tool Filtering: It is important not merely to block calls, but to remove unauthorized tools from the list entirely. This is because LLMs can attempt guesswork-based calls based solely on the tool name. This is the core of micro-segmentation.
Definition of Tool Poisoning: This is an attack in which a malicious MCP server inserts hidden commands into the tool description field. Since LLM considers tool metadata to be trusted instructions, tool metadata received from the server must be validated on the client side. The fact that client-side metadata validation is not mandatory under the MCP specification is currently the biggest structural weakness.
This is a minimal metadata validation pattern for defense. In production, it is recommended to integrate an LLM-based injection detection model in addition to this rule-based check.
// gateway/tools/metadata-validation.ts
function validateToolMetadata(tool: MCPTool): boolean {
const suspiciousPatterns = [
/ignore previous instructions/i,
/system:/i,
/<\|.*\|>/, // 특수 토큰 패턴
/\[INST\]/i, // 인젝션 마커
];
return !suspiciousPatterns.some((pattern) =>
pattern.test(tool.description),
);
}Pros and Cons Analysis
Advantages
| Item | Content |
|---|---|
| Centralized Policy Management | Since all MCP traffic passes through a single PEP, security policy changes are applied immediately to all agents |
| Complete Audit Trace | Structured logging is possible, including timestamps and results, showing which agent called which tool with which parameters. This structured log can meet the requirements for SOC 2 Type II access control traces, GDPR records of processing activities (Art. 30), and HIPAA access audit logs. |
| Explosion Radius Limit | Use the Epimeral Scope Token to limit the damage range to specific tasks and times upon agent breach |
| Dynamic Tool Filtering | You can change the set of exposed tools at runtime based on user context, role, and policy |
| Protocol Standardization | The new MCP server automatically inherits security policies simply by registering with the gateway |
| Low Latency Overhead | With an average additional latency of 4.47ms based on AWS metrics, the operational burden is minimal |
Disadvantages and Precautions
| Item | Content | Response Plan |
|---|---|---|
| Single Point of Failure (SPOF) | If the gateway goes down, all agent functions stop | High Availability (HA) deployment, circuit breaker pattern, retry logic applied |
| Stateful Session Complexity | MCP uses JSON-RPC-based stateful sessions. Since the Server-Sent Events (SSE) transport method maintains HTTP connections for a long time, sessions are dropped when a load balancer distributes requests to other instances. Switching to the HTTP Streamable method alleviates this somewhat, but Sticky Sessions or an external session store are required. | Designing Session Affinity (Sticky Session) or a Redis-based External Session Store |
| Supply Chain Risk | The MCP server itself may be untrusted code (CVE-2025-6514: mcp-remote OS command injection, over 430,000 downloads) | Server signature verification, implementation of SCA (Software Composition Analysis) pipeline |
| Prompt Injection · Tool Poisoning | Malicious servers can insert hidden commands into tooltips. The fact that client-side metadata validation is not mandatory in the specification is currently the biggest structural weakness. | Metadata validation (see Example 4), integration of LLM-based injection detection models |
| Implementation Complexity | The combination of OAuth 2.1 + Resource Indicators + Dynamic Token Exchange has a steep learning curve | Manage PATs for legacy servers with HashiCorp Vault; phased migration is recommended |
The Most Common Mistakes in Practice
- Directly injecting long-term API keys into the agent: This is the most common and fatal mistake. It was the direct cause of the 2025 Supabase Cursor agent breach. It is recommended to pass only unit-of-work epimeral tokens to the agent, and for long-term secrets, have the gateway mediate them from the Vault.
- Attempting to block semantic threats relying solely on a policy engine: While OPA/Cedar is robust for structured authority decisions, it cannot detect semantic attacks such as prompt injection or PII leakage. It is recommended to integrate ML-based injection detection and Data Loss Prevention (DLP) together at the gateway layer.
- Exploding operational complexity by over-segmenting policies: Creating separate policies for each tool, environment, and user makes management impossible. A realistic approach is to abstract using role-based policy templates and handle exceptions via overriding.
In Conclusion
Applying Zero Trust to an MCP gateway is not merely adding a security layer, but an architectural decision to explicitly define the boundaries of "what an agent can do" in the code.
Here are 3 steps you can start right now.
- It is recommended to start by enabling audit logs (possible within 1 week): Place
agentgatewayormcp-oauth-gatewayat the front of your existing stack and collect all tool calls in a structured format. Identifying "which agent calls what and how much" is the starting point for Zero Trust design. - You can express least privilege policies in code using OPA or Cerbos (a day is sufficient for the first version): An effective approach is to start with the simplest policies, such as "read-only roles are read tools only," and refine them by observing actual call patterns in logs. It is recommended to manage policy files with Git to keep a record of reviews and changes.
- You can develop a migration plan to replace long-term API keys with epimeral tokens: You can transition in stages, using the RFC 8693 token exchange pattern for servers supporting OAuth 2.1 and HashiCorp Vault's dynamic secrets feature for legacy servers. Rather than changing everything at once, it is more realistic to prioritize and proceed with the most sensitive tools first.
If you would like to continue receiving this series, please subscribe to the blog to receive notifications when new posts are uploaded.
Next Article: Delegation Patterns in MCP Multi-Agent Environments — Examining how to design token propagation and accountability tracking when an agent delegates authority to another agent.
Reference Materials
Beginner's Guide
- Model Context Protocol Official Site - Security Best Practices
- MCP and Zero Trust: Securing AI Agents With Identity and Policy | Cerbos
- The Agentic Trust Framework: Zero Trust Governance for AI Agents | CSA
- New tools and guidance: Announcing Zero Trust for AI | Microsoft Security Blog
Implementation Reference
- Advanced authentication and authorization for MCP Gateway | Red Hat Developer
- MCP Spec Updates from June 2025 - All About Auth | Auth0
- MCP Authorization the Easy Way – agentgateway
- agentgateway GitHub
- mcp-oauth-gateway GitHub
Increased Security
- Enterprise-Grade Security for the Model Context Protocol | arXiv 2504.08623
- MCP Security Vulnerabilities: How to Prevent Prompt Injection and Tool Poisoning Attacks | Practical DevSecOps
- OWASP GenAI - A Practical Guide for Securely Using Third-Party MCP Servers
- Building Zero-Trust Tooling for MCP: Interceptors, Scopes, and Policy-as-Code | Medium
- Zero Trust for autonomous agentic AI systems | Red Hat Emerging Technologies