Start the enforcer

Pair the enforcer (once)
mt-enforcer pair --api https://api.mutualtrust.io --state /var/lib/mt-enforcer # shows a pairing code; approve it in the portal (Services > your service > Pair gateway)
mt-enforcer forward-auth (traefik)
mt-enforcer forward-auth --format traefik --state /var/lib/mt-enforcer \ --listen 127.0.0.1:9191 --bundle-out /etc/traefik/dynamic/client-ca.pem

The bundle lands in the directory the file provider watches, so a bundle change reloads the TLS options without a restart.

Static configuration

services/enforcer/integrations/traefik/traefik.yml
# Mutual Trust with Traefik Proxy (tested with Traefik 3.7.19), static configuration. # Dynamic configuration (TLS client authentication, middlewares, routers) lives in # the watched directory, next to the client CA bundle mt-enforcer projects there, # so a bundle change reloads the TLS options without a restart. entryPoints: websecure: address: ":8443" forwardedHeaders: insecure: false # strip X-Forwarded-* from clients; do not add client networks to trustedIPs providers: file: directory: /etc/traefik/dynamic watch: true log: level: INFO

Dynamic configuration

services/enforcer/integrations/traefik/dynamic/mt.yml
# Mutual Trust with Traefik Proxy, dynamic configuration. # # mt-enforcer forward-auth --format traefik --state /var/lib/mt-enforcer \ # --listen 127.0.0.1:9191 --bundle-out /etc/traefik/dynamic/client-ca.pem # # The entry point removes every client-supplied X-Forwarded-* header (see # traefik.yml). passTLSClientCert then sets X-Forwarded-Tls-Client-Cert from # this connection, and forwardAuth sends it with the method and URI of the # request to the enforcer. trustForwardHeader: true is what lets forwardAuth # pass that header on; it is safe because nothing client-supplied survives the # entry point. authResponseHeadersRegex strips every inbound X-Mt-* header and # copies the verified ones from the enforcer's answer. Any non-2xx answer is # returned to the client unchanged. tls: certificates: - certFile: /etc/traefik/certs/server.pem # your server certificate keyFile: /etc/traefik/certs/server-key.pem options: default: minVersion: VersionTLS12 clientAuth: caFiles: [/etc/traefik/dynamic/client-ca.pem] # projected by mt-enforcer clientAuthType: RequireAndVerifyClientCert http: routers: api: rule: "PathPrefix(`/`)" entryPoints: [websecure] tls: {} middlewares: [mt-client-cert, mt-authz] service: origin middlewares: mt-client-cert: passTLSClientCert: pem: true mt-authz: forwardAuth: address: "http://127.0.0.1:9191/authz" trustForwardHeader: true authRequestHeaders: ["X-Forwarded-Tls-Client-Cert", "Traceparent"] authResponseHeadersRegex: "^X-Mt-" services: origin: loadBalancer: servers: - url: "http://origin:9001" # private network only

Why trustForwardHeader is on

The entry point removes every X-Forwarded-* header a client sends (forwardedHeaders.insecure: false, no trustedIPs). passTLSClientCert then sets X-Forwarded-Tls-Client-Cert from the connection, and forwardAuth forwards it, with the method and URI of the request, only when trustForwardHeader is true. Because nothing client-supplied survives the entry point, the enforcer always sees the real certificate, method and path. Keep client networks out of trustedIPs.

forwardAuth contract
OutcomeStatusHeaders and body
allow200x-mt-principal, x-mt-grant, x-mt-request-id, x-mt-policy-revision
deny403JSON problem (type, title, code, request_id); x-mt-reason and x-mt-request-id headers

Run with Docker Compose

services/enforcer/integrations/traefik/docker-compose.yaml
# Traefik + mt-enforcer (forwardAuth). # docker compose run --rm enforcer pair --api https://api.mutualtrust.io --state /var/lib/mt-enforcer # docker compose up -d services: enforcer: build: { context: ../.., dockerfile: Dockerfile } image: mt-enforcer:1.0.0 command: ["forward-auth", "--format", "traefik", "--state", "/var/lib/mt-enforcer", "--listen", "127.0.0.1:9191", "--admin", "0.0.0.0:9464", "--bundle-out", "/var/run/mt/bundle/client-ca.pem"] ports: ["8443:8443", "9464:9464"] volumes: ["mt-state:/var/lib/mt-enforcer", "mt-bundle:/var/run/mt/bundle"] healthcheck: { test: ["CMD", "/mt-enforcer", "healthcheck"], interval: 5s, retries: 30 } traefik: image: traefik:v3.7.19@sha256:aa942a00bce033c909bf1f7e861d93761be4b31a035f10d2410d33e38a8f41cf network_mode: "service:enforcer" depends_on: { enforcer: { condition: service_healthy } } command: ["--configFile=/etc/traefik/traefik.yml"] volumes: - ./traefik.yml:/etc/traefik/traefik.yml:ro - mt-bundle:/etc/traefik/dynamic:ro # client-ca.pem written by the enforcer - ./dynamic/mt.yml:/etc/traefik/dynamic/mt.yml:ro # same watched directory - ./certs:/etc/traefik/certs:ro origin: image: ${ORIGIN_IMAGE:-ghcr.io/your-org/orders-api:latest} volumes: { mt-state: {}, mt-bundle: {} }

Verify

Verify with the enrolled workload identity
mt enroll --token <token from portal> --api https://api.mutualtrust.io mt call https://orders-api.example.com:8443/orders/1 # 200, and your origin receives x-mt-principal: urn:mutualtrust:workload:... mt call https://orders-api.example.com:8443/admin/export # 403 {"code":"route_not_permitted", ...} # any other client: the files are in the workload directory that mt status prints W="$(dirname "$(mt status --json | jq -r .files.chain)")" curl --cert "$W/chain.pem" --key "$W/key.pem" https://orders-api.example.com:8443/orders/1

Tested

This configuration with Traefik 3.7.19 is run end to end by services/enforcer/test/real-gateways.sh: pairing, enrollment, allowed and restricted routes, a spoofed identity header, a request without a client certificate, an encoded dot-segment path, revocation inside the refresh window, and the signed acknowledgments and receipts the control plane receives.