Traefik
Traefik verifies client certificates against the bundle mt-enforcer projects into its watched configuration directory, and forwardAuth asks the enforcer about every request.
Start the enforcer
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 --format traefik --state /var/lib/mt-enforcer \
--listen 127.0.0.1:9191 --bundle-out /etc/traefik/dynamic/client-ca.pemThe bundle lands in the directory the file provider watches, so a bundle change reloads the TLS options without a restart.
Static configuration
# 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: INFODynamic configuration
# 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 onlyWhy 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.
| Outcome | Status | Headers and body |
|---|---|---|
| allow | 200 | x-mt-principal, x-mt-grant, x-mt-request-id, x-mt-policy-revision |
| deny | 403 | JSON problem (type, title, code, request_id); x-mt-reason and x-mt-request-id headers |
Run with Docker Compose
# 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
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/1Tested