NGINX
NGINX terminates mTLS against the client CA bundle mt-enforcer projects from the signed manifest, and calls the enforcer through auth_request for every request with a sub-request built from scratch.
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
| Outcome | Status | Headers and body |
|---|---|---|
| allow | 200 | x-mt-principal, x-mt-grant, x-mt-request-id, x-mt-policy-revision |
| deny | 401 (certificate problems) or 403 | JSON 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/1Tested