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 (nginx)
mt-enforcer forward-auth --format nginx --state /var/lib/mt-enforcer \ --listen 127.0.0.1:9191 --bundle-out /etc/mt/client-ca.pem \ --on-bundle-change "nginx,-s,reload"

NGINX configuration

services/enforcer/integrations/nginx/nginx.conf
# Mutual Trust with NGINX (tested with NGINX 1.30.6 built with http_ssl and # http_auth_request). # # NGINX terminates mTLS against the client CA bundle that mt-enforcer projects # from the signed manifest (--bundle-out, reloaded with --on-bundle-change), and # asks the enforcer about every request through auth_request. The sub-request # is built from scratch (proxy_pass_request_headers off): it carries only the # certificate of this connection and the original request line, so a client # cannot influence it. On allow, the verified identity is forwarded upstream and # overwrites any client-supplied x-mt-* value. # # mt-enforcer forward-auth --format nginx --state /var/lib/mt-enforcer \ # --listen 127.0.0.1:9191 --bundle-out /etc/mt/client-ca.pem \ # --on-bundle-change "nginx,-s,reload" worker_processes auto; events { worker_connections 1024; } http { access_log off; upstream mt_enforcer { server 127.0.0.1:9191; keepalive 16; } upstream origin { server origin:9001; keepalive 32; } # private network only server { listen 8443 ssl; http2 on; server_name _; ssl_certificate /etc/nginx/certs/server.pem; # your server certificate ssl_certificate_key /etc/nginx/certs/server-key.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_client_certificate /etc/mt/client-ca.pem; # projected by mt-enforcer ssl_verify_client on; ssl_verify_depth 2; location = /_mt_authz { internal; proxy_pass http://mt_enforcer/authz; proxy_http_version 1.1; proxy_pass_request_body off; proxy_pass_request_headers off; proxy_set_header Connection ""; proxy_set_header Content-Length ""; proxy_set_header X-SSL-Client-Cert $ssl_client_escaped_cert; proxy_set_header X-Original-Method $request_method; proxy_set_header X-Original-URI $request_uri; proxy_set_header Traceparent $http_traceparent; proxy_connect_timeout 1s; proxy_read_timeout 2s; } location / { auth_request /_mt_authz; auth_request_set $mt_principal $upstream_http_x_mt_principal; auth_request_set $mt_grant $upstream_http_x_mt_grant; auth_request_set $mt_request_id $upstream_http_x_mt_request_id; auth_request_set $mt_revision $upstream_http_x_mt_policy_revision; auth_request_set $mt_reason $upstream_http_x_mt_reason; error_page 401 = @mt_denied_401; error_page 403 = @mt_denied_403; error_page 500 = @mt_unavailable; # enforcer unreachable: fail closed proxy_set_header X-Mt-Principal $mt_principal; proxy_set_header X-Mt-Grant $mt_grant; proxy_set_header X-Mt-Request-Id $mt_request_id; proxy_set_header X-SSL-Client-Cert ""; # Method and URL overrides would make the origin run a request other than the one authorized. proxy_set_header X-HTTP-Method-Override ""; proxy_set_header X-HTTP-Method ""; proxy_set_header X-Method-Override ""; proxy_set_header X-Original-URL ""; proxy_set_header X-Rewrite-URL ""; proxy_set_header X-Original-Method ""; proxy_set_header X-Original-URI ""; proxy_set_header Connection ""; proxy_http_version 1.1; add_header X-Mt-Request-Id $mt_request_id always; add_header X-Mt-Policy-Revision $mt_revision always; proxy_pass http://origin; } location @mt_denied_401 { default_type application/json; add_header X-Mt-Request-Id $mt_request_id always; add_header X-Mt-Reason $mt_reason always; return 401 '{"type":"https://mutualtrust.io/problems/$mt_reason","status":401,"code":"$mt_reason","request_id":"$mt_request_id"}'; } location @mt_denied_403 { default_type application/json; add_header X-Mt-Request-Id $mt_request_id always; add_header X-Mt-Reason $mt_reason always; return 403 '{"type":"https://mutualtrust.io/problems/$mt_reason","status":403,"code":"$mt_reason","request_id":"$mt_request_id"}'; } location @mt_unavailable { default_type application/json; return 503 '{"code":"enforcer_unavailable"}'; } } }

Contract

auth_request contract
OutcomeStatusHeaders and body
allow200x-mt-principal, x-mt-grant, x-mt-request-id, x-mt-policy-revision
deny401 (certificate problems) or 403JSON problem (type, title, code, request_id); x-mt-reason and x-mt-request-id headers

auth_request understands only 2xx, 401 and 403, so certificate problems are 401 and authorization denials are 403. The named locations turn them into JSON problems with the reason; an unreachable enforcer is a 500 from the sub-request, which the configuration maps to 503.

The sub-request carries only X-SSL-Client-Cert ($ssl_client_escaped_cert, the certificate of this connection), X-Original-Method, X-Original-URI and Traceparent: proxy_pass_request_headers off keeps client headers out of it.

Run with Docker Compose

services/enforcer/integrations/nginx/docker-compose.yaml
# NGINX + mt-enforcer (auth_request). NGINX shares the enforcer's network namespace # and the projected client CA bundle. # 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", "nginx", "--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 } nginx: image: nginx:1.30.6-alpine@sha256:901e593c95dc85f5a729f5765516321366ef4ac401821431d408897e95287d0a network_mode: "service:enforcer" depends_on: { enforcer: { condition: service_healthy } } volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro - ./certs:/etc/nginx/certs:ro - mt-bundle:/etc/mt:ro # The bundle changes when issuers rotate; reload NGINX to pick it up, for example # from a sidecar or cron: docker compose exec nginx nginx -s reload 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 NGINX 1.30.6 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.