The MCP Gateway is an Envoy-based gateway that aggregates multiple Model Context Protocol (MCP) servers behind a single endpoint, enabling AI agents to access tools from diverse sources through a unified interface. While this aggregation simplifies the client experience, it introduces critical security challenges: How do you control which users can access which tools across multiple MCP servers? How do you prevent overly permissive access tokens from being leaked to untrusted servers? And how do you handle MCP servers that use different authentication mechanisms?
This post explores three advanced authentication and authorization capabilities we've implemented for the MCP Gateway, using Kuadrant as an add-on to provide enterprise-grade security without creating hard dependencies.
The security challenge
When an MCP Gateway aggregates tools from multiple servers, several security challenges emerge:
- Overly permissive tokens: AI agents typically use OAuth2 access tokens with broad scopes covering all tools across all MCP servers. If these tokens are passed directly to backend servers, a compromised or malicious MCP server could use them to impersonate the agent and access unauthorized resources.
- Coarse-grained access control: The standard MCP protocol doesn't provide a mechanism for filtering which tools are visible based on user identity. An agent sees all available tools, even if the user isn't authorized to use some of them. This problem is exacerbated when the MCP Broker component of the MCP Gateway aggregates tools from multiple MCP servers, including servers possibly unrelated to user's group associations and roles.
- Heterogeneous authentication: Different MCP servers might require different authentication methods—some use OAuth2, others use personal access tokens (PATs), and some use API keys. The gateway needs to translate between these mechanisms transparently.
Solution overview
We've addressed these challenges through three complementary capabilities:
- Identity-based tool filtering: Filter the tools/list response based on user permissions using a cryptographically signed header.
- OAuth2 Token Exchange: Use RFC 8693 to exchange broad access tokens for narrowly-scoped tokens specific to each MCP server.
- Vault integration: Retrieve PATs and API keys from HashiCorp Vault for servers that don't support OAuth2.
All three capabilities are implemented using Kuadrant's AuthPolicy resource, which integrates with the MCP Gateway's Envoy-based architecture. However, the gateway itself remains agnostic to the authentication mechanism—you could implement these patterns using any Istio/Gateway API compatible policy engine.
Watch a demo of the full solution in action:
1. Identity-based tool filtering
The problem: When an agent calls tools/list, the MCP Gateway returns all tools from all registered MCP servers. But what if the user is only authorized to access a subset of these tools? Showing unauthorized tools creates confusion and potential security issues.
The solution: We use a trusted header approach with cryptographic verification:
- An external authorization component (Authorino in our case) validates the user's OAuth2 token and extracts their permissions from the identity provider.
- It creates a signed JWT "wristband" that holds the permitted tools and injects it as the x-authorized-tools header.
- The MCP Broker validates this JWT using a trusted public key and filters the tool list accordingly.
Here's the AuthPolicy configuration:
apiVersion: kuadrant.io/v1
kind: AuthPolicy
metadata:
name: mcp-auth-policy
namespace: gateway-system
spec:
targetRef:
group: gateway.networking.k8s.io
kind: Gateway
name: mcp-gateway
sectionName: mcp
when:
- predicate: "!request.path.contains('/.well-known')"
rules:
authentication:
'keycloak':
jwt:
issuerUrl: https://keycloak.example.com/realms/mcp
authorization:
'allow-tool-list':
patternMatching:
patterns:
- predicate: request.headers['x-mcp-method'] in ["tools/list","initialize","notifications/initialized"]
'authorized-tools':
opa:
rego: |
allow = true
tools = { server: roles |
server := object.keys(input.auth.identity.resource_access)[_];
roles := object.get(input.auth.identity.resource_access, server, {}).roles
}
allValues: true
response:
success:
headers:
x-authorized-tools:
wristband:
issuer: 'authorino'
customClaims:
'allowed-tools':
selector: auth.authorization.authorized-tools.tools.@tostr
tokenDuration: 300
signingKeyRefs:
- name: trusted-headers-private-key
algorithm: ES256How it works:
- The policy uses OPA (Open Policy Agent) to extract tool permissions from the JWT's
resource_accessclaim. - Keycloak stores permissions as client roles, where each MCP server is a resource server client and each tool is a role.
- The wristband feature creates a signed JWT containing the allowed tools mapping (for example,
{"server1.mcp.local":["greet","time"],"server2.mcp.local":["headers"]}). - The broker validates this JWT and filters tools before returning the response.
On the broker side, the implementation is straightforward (internal/broker/filtered_tools_handler.go):
func (broker *mcpBrokerImpl) FilteredTools(ctx context.Context, _ any,
mcpReq *mcp.ListToolsRequest, mcpRes *mcp.ListToolsResult) {
// Get the x-authorized-tools header
allowedToolsValue := mcpReq.Header[authorizedToolsHeader][0]
// Validate the JWT signature
parsedToken, err := validateJWTHeader(allowedToolsValue, broker.trustedHeadersPublicKey)
// Extract the allowed-tools claim
authorizedTools := map[string][]string{}
json.Unmarshal([]byte(allowedToolsValue), &authorizedTools)
// Filter tools based on permissions
mcpRes.Tools = broker.filterTools(authorizedTools)
}The benefits include:
- Least privilege: Users only see tools they're authorized to use.
- Cryptographically secure: The broker verifies the header signature, preventing tampering.
- Transparent to clients: No changes needed to MCP client implementations.
- Flexible permissions model: Supports any identity provider that can issue JWT claims.
2. OAuth2 Token Exchange
The problem: AI agents typically authenticate with a single OAuth2 access token that has broad scopes and multiple audiences. Passing this token to every backend MCP server creates a privilege escalation risk—a malicious server could use the token to access other services the user has access to.
The solution: We implement RFC 8693 OAuth2 Token Exchange to convert the broad access token into narrowly-scoped tokens specific to each MCP server:
apiVersion: kuadrant.io/v1
kind: AuthPolicy
metadata:
name: mcps-auth-policy
namespace: gateway-system
spec:
targetRef:
group: gateway.networking.k8s.io
kind: Gateway
name: mcp-gateway
sectionName: mcps
rules:
authentication:
'keycloak':
jwt:
issuerUrl: https://keycloak.example.com/realms/mcp
metadata:
oauth-token-exchange:
http:
url: https://keycloak.example.com/realms/mcp/protocol/openid-connect/token
method: POST
credentials:
authorizationHeader:
prefix: Basic
sharedSecretRef:
name: token-exchange
key: oauth-client-basic-auth
bodyParameters:
grant_type:
value: urn:ietf:params:oauth:grant-type:token-exchange
subject_token:
expression: request.headers['authorization'].split('Bearer ')[1]
subject_token_type:
value: urn:ietf:params:oauth:token-type:access_token
audience:
expression: request.host # Target MCP server hostname
scope:
value: openid
authorization:
'token':
opa:
rego: |
scoped_jwt := object.get(object.get(object.get(input.auth, "metadata", {}),
"oauth-token-exchange", {}), "access_token", "")
jwt := j { scoped_jwt != ""; j := scoped_jwt }
jwt := j { scoped_jwt == ""; j := split(input.request.headers["authorization"], "Bearer ")[1] }
claims := c { [_, c, _] := io.jwt.decode(jwt) }
allow = true
allValues: true
'scoped-audience-check':
patternMatching:
patterns:
- predicate: has(auth.authorization.token.claims.aud) &&
type(auth.authorization.token.claims.aud) == string &&
auth.authorization.token.claims.aud == request.host
'tool-access-check':
patternMatching:
patterns:
- predicate: |
request.headers['x-mcp-toolname'] in (has(auth.authorization.token.claims.resource_access) &&
auth.authorization.token.claims.resource_access.exists(p, p == request.host) ?
auth.authorization.token.claims.resource_access[request.host].roles : [])
response:
success:
headers:
authorization:
plain:
expression: "Bearer " + auth.authorization.token.jwtHow it works:
- The MCP Router sets the
x-mcp-toolnameheader based on the tool being called. - The
AuthPolicycalls the token exchange endpoint, passing the original token and the target MCP server hostname as the audience. - The identity provider issues a new token with:
audclaim set to the target MCP server only.- Scopes limited to what's needed for that server.
- Same user identity claims.
- Authorization checks verify:
- The exchanged token has the correct audience.
- The user has permission to access the requested tool.
- The exchanged token replaces the original in the Authorization header.
The benefits include:
- Least privilege tokens: Each backend server receives only the access it needs.
- Prevents lateral movement: A compromised server can't use the token to access other services.
- Standards-based: Uses RFC 8693, supported by major identity providers.
- Transparent: No changes to MCP server implementations.
3. HashiCorp Vault integration
The problem: Not all MCP servers support OAuth2. Many external services (like GitHub's MCP server) require PATs or API keys. Managing these credentials securely while still using OAuth2 for user authentication creates an integration challenge.
The solution: We use the AuthPolicy metadata feature to fetch credentials from HashiCorp Vault, indexed by user identity and target server:
metadata:
vault:
http:
urlExpression: |
"http://vault.vault.svc.cluster.local:8200/v1/secret/data/" +
auth.identity.preferred_username + "/" + request.host
method: GET
credentials:
customHeader:
name: X-Vault-Token
sharedSecretRef:
name: token-exchange
key: vault-token
priority: 0 # Try Vault first
oauth-token-exchange:
when:
- predicate: "!has(auth.metadata.vault.data) ||
!has(auth.metadata.vault.data.data) ||
!has(auth.metadata.vault.data.data.token) ||
type(auth.metadata.vault.data.data.token) != string"
# ... token exchange config ...
priority: 1 # Fallback to token exchange if Vault has no entry
The response injection logic checks for Vault credentials first:
response:
success:
headers:
authorization:
plain:
expression: |
"Bearer " + ((has(auth.metadata.vault.data) &&
has(auth.metadata.vault.data.data) &&
has(auth.metadata.vault.data.data.token) &&
type(auth.metadata.vault.data.data.token) == string) ?
auth.metadata.vault.data.data.token :
auth.authorization.token.jwt)How it works:
- The
AuthPolicyfirst tries to fetch a credential from Vault using a path like/v1/secret/data/alice/github.mcp.local. - If found, it uses that credential (PAT or API key) in the Authorization header.
- If not found, it falls back to OAuth2 token exchange.
- This allows integration with external services that don't support OAuth2.
The benefits include:
- Centralized secret management: Credentials stored securely in Vault, not in code or config.
- Per-user, per-service credentials: Each user can have their own PATs for external services.
- Fallback strategy: Gracefully handles both OAuth2 and legacy authentication.
- Audit trail: Vault provides logging of all credential access.
Implementation architecture
The complete flow combines all three capabilities:
┌───────────────┐
│ MCP Client │
│ (Agent) │
└──────┬────────┘
│ OAuth2 Token (broad scopes)
▼
┌───────────────────────────────────────────────┐
│ Gateway (Envoy + Kuadrant AuthPolicies) │
│ │
│ 1. Validate JWT │
│ 2. Extract tool permissions │
│ 3. Create x-authorized-tools wristband │
│ 4. Check Vault for credentials │
│ 5. Or exchange token (RFC 8693) │
│ 6. Verify tool access authorization │
└────────┬──────────────┬───────────────────────┘
│ │
▼ ▼
┌────────────┐ ┌─────────────┐
│ MCP Broker │ │ MCP Router │
│ │ │ (ext_proc) │
│ - Validates│ │ │
│ x-author │ │ - Sets │
│ ized-tools│ │ x-mcp- │
│ - Filters │ │ toolname │
│ tool list│ │ - Routes to │
│ │ │ backend │
└────────────┘ └──────┬──────┘
│ Scoped token or PAT
▼
┌─────────────┐
│ Backend MCP │
│ Servers │
└─────────────┘Key design decisions
- No hard dependencies: The MCP Gateway components (broker, router, controller) don't depend on Kuadrant. They expose extension points (headers, metadata) that any policy engine can use.
- Defense in depth: Multiple layers of security:
- Gateway-level authentication (
AuthPolicy) - Tool-level authorization (
x-mcp-toolnamechecks) - Cryptographic verification (wristband signatures)
- Token scoping (audience and scope reduction)
- Gateway-level authentication (
- Envoy-first design: All security logic runs in Envoy filters, ensuring consistent policy enforcement regardless of backend implementation.
- Gateway API integration: Uses standard Gateway API resources (HTTPRoute, Gateway listeners) for routing, making it compatible with any Gateway API provider.
Real-world example
Here's how these capabilities work together in a real scenario.
Alice, a developer, wants to use an AI agent to access tools from three MCP servers:
- Internal code review tools (OAuth2)
- GitHub repository tools (requires GitHub PAT)
- Weather forecast tools (API key)
Flow:
- Alice authenticates with her identity provider (Keycloak), receiving an OAuth2 access token with audiences for all three servers
- The agent calls tools/list:
AuthPolicyvalidates the token- OPA extracts Alice's permissions:
{"codereview.local":["analyze_pr","suggest_fix"], "github.mcp.local":["list_repos"], "weather.local":["get_forecast"]} - Wristband creates signed JWT with these permissions
- Broker returns only the 4 tools Alice can access
- The agent calls
codereview_analyze_pr:- Router sets x-mcp-toolname:
analyze_pr and :authority: codereview.local AuthPolicyexchanges Alice's token for one withaud=codereview.local- Verifies Alice has the
analyze_prrole for that server - Routes with scoped token
- Router sets x-mcp-toolname:
- The agent calls
github_list_repos:- Router sets
x-mcp-toolname:list_repos and :authority: github.mcp.local AuthPolicychecks Vault at/v1/secret/data/alice/github.mcp.local- Finds Alice's GitHub PAT
- Routes with
Authorization: Bearer ghp_...
- Router sets
- The agent calls
weather_get_forecast:- Similar flow, but retrieves API key from Vault
Throughout this flow:
- Alice never sees the GitHub PAT or weather API key.
- Each backend server receives only the credentials it needs.
- No server receives Alice's original OAuth2 token.
- Tool access is verified at every step.
Try it yourself
The MCP Gateway repository includes a complete working example:
# Set up a local Kind cluster with everything
git clone git@github.com:kagenti/mcp-gateway.git && cd mcp-gateway
make local-env-setup
# Configure OAuth with token exchange and Vault
make oauth-token-exchange-example-setup
# Open the MCP Inspector to test
make inspect-gatewayThis sets up:
- Keycloak with example users, groups, and tool permissions
- MCP Gateway with all three auth capabilities enabled
- HashiCorp Vault with example credentials
- Test MCP servers demonstrating different auth methods
See the documentation for detailed configuration guides.
Conclusion
Securing aggregated MCP servers requires more than basic authentication. You need fine-grained authorization, token scoping, and support for heterogeneous authentication mechanisms. By leveraging Kuadrant's AuthPolicy as an add-on, the MCP Gateway can provide enterprise-grade security while maintaining flexibility and avoiding hard dependencies.
These capabilities demonstrate how modern API gateway patterns can be adapted to the unique challenges of AI agent security, providing the controls needed to safely expose powerful tools to autonomous systems.
Next steps:
- Explore the MCP Gateway documentation
- Read about AuthPolicy
- Try the OAuth example setup
- Join the discussion on GitHub