Secure MCP with OAuth
The MCP Gateway guide describes authenticating AI agents at the gateway before any tool call reaches an upstream. This tutorial adds a jwt guard, using JWT bearer, to the two-upstream gateway from Get Started, validated against a JWKS endpoint.
Editions
JWT bearer is fully open source: it reuses the OSS jwt guard and the mcp server binding's own options.authorization, neither of which is Zilla Plus gated.
See JWT Bearer for the guard reference.
Prerequisites
- Docker Compose
- The
mcp.proxyexample running from Get Started - An OIDC-compliant identity provider that issues JWTs and exposes a
.well-known/jwks.jsonendpoint (Auth0, Okta, Keycloak, or your own)
Add a JWT Guard
Define a jwt guard with your identity provider's issuer and audience. If the issuer exposes a .well-known/jwks.json file, Zilla fetches the signing keys remotely; nothing else is required:
guards:
agent_jwt:
type: jwt
options:
issuer: https://auth.example.com
audience: https://mcp.example.comSee the jwt guard reference for manually configured keys, the roles claim, and the challenge window.
Guard the Gateway Entrypoint
Auth runs directly on north_mcp_server, the binding that terminates the AI agent's connection, through its own options.authorization field. north_http_server stays a plain HTTP-to-MCP router, with no auth logic of its own: it just routes the /mcp path to north_mcp_server, which validates the session itself before forwarding to the MCP proxy.
north_mcp_server:
type: mcp
kind: server
options:
authorization:
agent_jwt:
credentials: "Bearer {credentials}"
exit: north_mcp_proxyoptions.authorization names the agent_jwt guard and a credentials template, Bearer {credentials}, matched against the inbound Authorization header. A request whose header doesn't match the template, or whose extracted token the guard rejects, is turned away at north_mcp_server with a 401 response and a WWW-Authenticate challenge, before it ever reaches the MCP proxy or either upstream MCP server.
Full zilla.yaml config
name: secure-mcp-oauth
#region guard
guards:
agent_jwt:
type: jwt
options:
issuer: https://auth.example.com
audience: https://mcp.example.com
#endregion guard
stores:
cache:
type: memory
bindings:
north_tcp_server:
type: tcp
kind: server
options:
host: 0.0.0.0
port: 7114
routes:
- when:
- port: 7114
exit: north_http_server
north_http_server:
type: http
kind: server
options:
access-control:
policy: cross-origin
routes:
- when:
- headers:
":path": /mcp
exit: north_mcp_server
#region authorization
north_mcp_server:
type: mcp
kind: server
options:
authorization:
agent_jwt:
credentials: "Bearer {credentials}"
exit: north_mcp_proxy
#endregion authorization
telemetry:
metrics:
- mcp.*
attributes:
method: ${mcp.method}
tool: ${mcp.tool}
outcome: ${mcp.outcome}
north_mcp_proxy:
type: mcp
kind: proxy
options:
cache:
store: cache
ttl: PT5M
routes:
- exit: bluesky_mcp_client
when:
- toolkit: bluesky
- exit: payments_mcp_client
when:
- toolkit: payments
bluesky_mcp_client:
type: mcp
kind: client
options:
server: http://bluesky-mcp:3001/mcp
exit: sys:http_client
payments_mcp_client:
type: mcp
kind: client
options:
server: http://payments-mcp:3002/mcp
exit: sys:http_client
telemetry:
metrics:
- mcp.initialize
- mcp.initialize.duration
- mcp.tools.list
- mcp.tools.list.duration
- mcp.tools.call
- mcp.tools.call.duration
exporters:
prometheus_exporter:
type: prometheus
options:
endpoints:
- scheme: http
port: 7190
path: /metricsApply and Verify
Update zilla.yaml in zilla/examples/mcp.proxy with the guard and north_mcp_server's options.authorization, then reload:
docker compose --project-directory mcp.proxy up -dA request to /mcp with an Authorization header that doesn't match the Bearer {credentials} template, or that carries a token the agent_jwt guard rejects, is turned away at north_mcp_server with a 401 response, before it reaches the MCP proxy or either upstream MCP server:
curl -i http://localhost:7114/mcp -H 'Authorization: Bearer not-a-valid-token'A request with no Authorization header at all is admitted as unauthorized rather than rejected outright, the same as an unauthenticated request reaching an unguarded route anywhere else in Zilla:
curl -i http://localhost:7114/mcpA request with a valid bearer token issued by your identity provider is authenticated and forwarded as before:
curl -i http://localhost:7114/mcp -H 'Authorization: Bearer <token>'When you're done, stop the stack:
docker compose --project-directory mcp.proxy downNext Steps
- Add Kafka as MCP Tools or MCP Gateway Setup upstreams behind the same guarded entrypoint.

