Skip to content

5.5. Gateway Security

Which controls are active in every profile?

  • MCP allows exactly six read tools and fails closed when the backend is unavailable.
  • The MCP backend keeps DNS-rebinding protection enabled and accepts only explicit host authorities.
  • MCP, A2A, and model listeners have separate local token-bucket limits.
  • Model requests reject detected email addresses and a narrow ignore/override-instruction pattern.
  • Model responses reject detected email addresses with status 502.
  • JSON logs and Prometheus metrics record gateway outcomes; the Kubernetes profiles additionally export OTLP gateway traces.
  • Kubernetes exposes only ClusterIP services plus namespace/pod network policies.
  • The Kubernetes profiles additionally require the demo API key on the model listener; the host default stays open, and the secured host profile below authenticates all three listeners.

These are real shipped controls, not commented examples. Chapter 5.6 explains why the host profile keeps gateway OTLP disabled until the in-cluster observability path.

The native-Linux host relay binds only Docker's default bridge address, but that is not per-container authorization: other trusted containers on that bridge can reach its relayed upstreams and metrics. Do not share the default bridge with untrusted containers; stronger multi-tenant isolation needs a dedicated network and host firewall policy.

How does the prompt guard work?

ai:
  promptGuard:
    request:
      - regex:
          action: reject
          rules:
            - builtin: email
            - pattern: "(?i)(ignore|override).{0,40}(instructions|system prompt)"
        rejection:
          status: 400
          body: Request rejected by the course prompt guard.
    response:
      - regex:
          action: reject
          rules:
            - builtin: email
        rejection:
          status: 502

Test request rejection through :4000:

curl -i http://localhost:4000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{
    "model": "qwen3:4b",
    "messages": [{"role": "user", "content": "Email jane.doe@example.com"}]
  }'

Expected status: 400 before Ollama is called.

Why is regex not enough?

Attackers can paraphrase, encode, split, translate, or move instructions into retrieved/tool content. Regex also creates false positives. Layer it with application callbacks, tool allowlists, argument validation, approval, retrieval provenance, call budgets, adversarial tests, and monitoring. Never advertise a small pattern list as prompt-injection prevention.

How is cloud authentication separated?

The agent sends a marker or real client credential to the gateway endpoint. On GKE, only the agentgateway Kubernetes service account maps to a Google service account with Vertex permissions. MLflow has a different identity scoped to its GCS bucket. No static cloud key is mounted into either pod.

How do callers authenticate to the gateway?

The default host profile stays unauthenticated so Chapters 5.1-5.4 run without extra steps. The opt-in secured profile infra/agentgateway/host/config-auth.yaml adds the two missing fundamentals on the same ports: identity before authorization, and encryption in transit.

  1. Start the secured profile from the repository root. The task generates demo-only material in a gitignored directory, stages only the listener certificate/private key and public JWKS, then starts the hardened loopback wrapper in the foreground:
mise run gateway:host:auth

The CA key and JWT signing key never enter the container. The secured profile preserves the default wrapper's digest pin, non-root UID, read-only filesystem, dropped capabilities, loopback-only published ports, and ownership-scoped cleanup.

  1. In another terminal, mint a caller token. The script signs an RS256 JWT (iss=agentops-course, aud=agentops-gateway, one-hour expiry) with a local key whose public JWKS the gateway trusts:
TOKEN="$(infra/scripts/gateway-jwt.sh ops-admin)"

Verify identity enforcement on the A2A listener (with the Chapter 5.1 host stack running):

CA=infra/agentgateway/host/auth/ca-cert.pem
curl -s -o /dev/null -w '%{http_code}\n' --cacert "$CA" \
  https://localhost:3001/.well-known/agent-card.json
curl -s -o /dev/null -w '%{http_code}\n' --cacert "$CA" \
  -H "Authorization: Bearer $TOKEN" \
  https://localhost:3001/.well-known/agent-card.json

Expected: 401 without a token, 200 with one.

The verified identity then feeds the existing MCP tool authorization: the same CEL rules from Chapter 5.2 gain a jwt.sub condition, so different callers see different tools.

mcpAuthorization:
  rules:
    - 'jwt.sub == "ops-admin" && mcp.tool.name == "search_service_logs"'
    - 'jwt.sub == "ops-viewer" && mcp.tool.name == "list_incidents"'

Rerun the Chapter 5.2 tool listing against https://localhost:3000/mcp — pass ssl.create_default_context(cafile=...) as verify and an Authorization header to the httpx.AsyncClient. An ops-admin token lists all six read tools, an ops-viewer token only list_incidents and get_incident, and no token is rejected with 401 before any MCP handling.

The model listener uses an API key instead of a JWT, because the OpenAI SDK already sends OPENAI_API_KEY as a Bearer header: the local marker can become an enforced credential with no application-code change. The Kubernetes profiles use the value from the agentgateway-client Secret — so a port-forwarded curl to a cluster gateway needs its configured bearer value.

Be honest about the scope: the JWT issuer is a local script, not an identity provider, and the API key value is public in this repository. Both demonstrate the mechanism; production needs an OIDC/IdP-issued token, secret key material, rotation, and short lifetimes. The gateway validates the A2A caller, but this lab does not propagate the JWT subject into ADK's action ToolContext; the application audit still records the synthetic A2A context user. In-cluster MCP and A2A listeners stay token-free for now — see the unauthenticated section below.

How does the agent connect when authentication is on?

The agent's model route works with environment variables only, exactly as in Chapter 5.4 plus trust for the demo certificate:

AGENT_MODEL_PROVIDER=openai-compatible
AGENT_MODEL=qwen3:4b
OPENAI_BASE_URL=https://127.0.0.1:4000/v1
OPENAI_API_KEY=agentgateway
SSL_CERT_FILE=../../infra/agentgateway/host/auth/ca-cert.pem

The path is relative to agents/python, where model-backed mise tasks execute. Set SSL_CERT_FILE only in the agent process: it replaces the default trust store, so exporting it shell-wide breaks every other HTTPS call in that shell.

The agent's MCP client now closes this seam: set AGENT_MCP_TOKEN to a demo JWT (mint one with ./infra/scripts/gateway-jwt.sh) and ops_mcp_toolset() attaches it as a Bearer header on the streamable-HTTP connection. The token is a SecretStr, so mise run config:check masks it like every other credential. With AGENT_MCP_URL pointed at the secured gateway and AGENT_MCP_TOKEN set, the agent lists tools through the authenticated route end to end; leave the token unset for the open local profile and no header is sent.

How do I turn on TLS locally?

infra/scripts/gateway-tls.sh generates a local CA and a CA-signed server certificate for localhost/127.0.0.1 (30-day validity, gitignored). Clients trust ca-cert.pem; the secured profile terminates TLS on its MCP, A2A, and model listeners:

listeners:
  - name: a2a
    protocol: HTTPS
    tls:
      cert: infra/agentgateway/host/auth/tls-cert.pem
      key: infra/agentgateway/host/auth/tls-key.pem

Verify encryption in transit:

  1. curl --cacert "$CA" https://localhost:3001/... with a token returns 200 — the client verified the exact certificate it was given.
  2. The same request without --cacert fails certificate verification: nothing else trusts this lab certificate, by design.
  3. A plaintext http://localhost:3001/... request fails at the connection: the listener only speaks TLS.

This is lab trust, not public PKI: no real CA, hostname set to localhost, no rotation or revocation. On GKE the course keeps ClusterIP services plus kubectl port-forward; real exposure would instead terminate TLS at a public edge with managed or ACME certificates (or run mesh mTLS), which this course does not implement.

What stays unauthenticated and why?

  1. Gateway metrics on :15020 — an internal listener scraped by Prometheus and the in-cluster collector, carrying operational counters rather than request content. Adding auth would break the pinned scrape configs for little gain; network scope (loopback use, ClusterIP plus a NetworkPolicy admitting only the collector) is the actual control.
  2. The Kubernetes agentgateway readiness/liveness probes — they use tcpSocket connects against its MCP port, so caller authentication does not affect them.
  3. The default host profile — a deliberate learning-friction trade-off on loopback upstreams; the secured profile exists precisely to remove it once the basics work.
  4. The in-cluster MCP and A2A listeners — the agent's ADK McpToolset can now send a Bearer token (AGENT_MCP_TOKEN, see above), but kagent's RemoteMCPServer still does not, and the A2A listener has no client-side token path yet, so enforcing JWT cluster-wide would break the running platform. NetworkPolicies restricting callers to declared namespaces are the compensating control, and this remaining gap is listed as absent work, not claimed as secured.

Why is the local rate limit not a quota?

Its state belongs to one gateway instance and has no authenticated user dimension. It reduces accidental bursts in this single-replica lab. Production budgets require identity, per-tenant policy, shared state, alerting, and a decision for rejected/queued work.

Which security controls are intentionally absent?

No OIDC/external identity provider, verified A2A-subject propagation into action audit, mTLS, public ingress, WAF, distributed authorization, signed request, or external audit store is configured. Caller authentication and TLS exist only as the opt-in local demonstrations above, built on script-generated demo material and a repository-visible API key — mechanisms to learn from, not managed identity or public PKI. The lab remains loopback-only or ClusterIP/port-forwarded. Chapter 6 preserves that private posture on GKE.

What is the security checkpoint?

Verify an email prompt returns 400, the six MCP reads are visible, writes are absent, a malformed action fails in the application, and gateway logs/metrics show each decision. With the secured profile, additionally verify that a request without a token returns 401 and that ops-admin and ops-viewer tokens list different tools. Document bypasses as regression cases rather than expanding a regex without evidence.

When you finish the secured-profile lab, tear down its material with rm -r infra/agentgateway/host/auth. The directory is gitignored and deliberately excluded from the filesystem scan because generating it is part of the lab; staged/full-history gitleaks still protects the repository boundary. Teardown minimizes local secret lifetime, and the scripts regenerate everything on demand.