Gateways
Mutual Trust enforcement runs inside your own ingress. The enforcer, a single static binary, verifies the signed policy for your service and decides every request; your gateway terminates mTLS and asks it, or the enforcer terminates mTLS itself.
How enforcement works
- Pair.
mt-enforcer pairgenerates a gateway key locally, shows a pairing code, and after you approve it collects a scoped gateway credential and pins the policy-verification key of your trust space. - Verify policy. Every 15 seconds (with jitter) the enforcer fetches the signed manifest, checks the ES256 signature against the pinned key, the tenant, environment, service and gateway it was issued for, the policy hash, and the high-water mark it keeps on disk (an older or conflicting revision is refused). Policy and trust bundle are swapped together, and a signed acknowledgment goes back to the control plane.
- Project the bundle. The client CA bundle from the manifest is written where the gateway reads it (a PEM file or an Envoy SDS resource), by atomic rename.
- Decide every request. The enforcer re-validates the client leaf against the signed bundle with its own X.509 path validation (leaf, issuing CA, root, clientAuth), then runs the same deny-by-default policy as the control plane: issuer, single workload URI SAN, expiry, revoked serials, grant state, normalized route and method. A valid chain alone never authorizes a call.
- Report. Each decision becomes a signed receipt that records the TLS verdict and the authorization verdict separately; receipts are batched and retried with backoff.
Modes
| Mode | Use it with | What it does |
|---|---|---|
proxy | Standalone mTLS proxy, AWS ALB | Terminates mTLS (or, behind a load balancer, reads the forwarded certificate), authorizes, and proxies allowed requests to your origin with the verified identity. |
envoy-grpc | Envoy, Istio | Envoy external authorization gRPC service (envoy.service.auth.v3.Authorization/Check). |
forward-auth | NGINX, ingress-nginx, Traefik, HAProxy, Kong, Caddy | HTTP authorization endpoint the gateway calls for every request. |
lambda | AWS API Gateway | Lambda authorizer for HTTP APIs and REST APIs with mutual TLS custom domains. |
| Worker | Cloudflare | A Cloudflare Worker on your zone runs the same verification at the edge. |
Install
The enforcer is built into the mt CLI: mt enforcer proxy, mt enforcer forward-auth and every other mode take the same flags as the standalone mt-enforcer binary, and the .deb and .rpm packages ship an mt-enforcer systemd unit. The standalone binary, for containers, Lambda and sidecars, is published for macOS, Linux and Windows with SHA-256 checksums.
curl -fsSL https://mutualtrust.io/install.sh | sh # mt, with the enforcer built in
# the standalone mt-enforcer binary
mt enforcer install
# or download directly
curl -fLO https://mutualtrust.io/downloads/enforcer/linux-amd64/mt-enforcer
curl -fL https://mutualtrust.io/downloads/enforcer/SHA256SUMS | grep linux-amd64 | sed 's#linux-amd64/##' | shasum -a 256 -c
# container image
docker build -t mt-enforcer:1.0.0 services/enforcerHeader hygiene
Only three headers carry identity to your origin, and the gateway always sets them from the enforcer's answer: x-mt-principal (the workload URI), x-mt-grant and x-mt-request-id. Every integration removes or overwrites client-supplied x-mt-* values, and every forwarded-certificate header is set by the gateway from the connection it terminated, never passed through from the client.
In forward-auth mode the enforcer accepts calls only from trusted_proxies (loopback by default), so a certificate header is only read when it came from your gateway. Bind your origin to the gateway over private networking so it cannot be reached directly.
Fail closed
A manifest is valid for 5 minutes; the enforcer refreshes every 15 seconds and keeps enforcing a valid cached manifest while the control plane is unreachable. Once the manifest is older than its expiry plus a 30 second clock allowance, every protected request is denied with manifest_expired, /readyz turns 503, and the proxy closes active client connections. If the enforcer itself is unreachable, each integration denies the request (for example Envoy failure_mode_allow: false).
The last verified manifest and the high-water mark are kept in the data directory, so a restarted enforcer resumes enforcing at once and never accepts an older revision.
Observability
The admin listener (default 127.0.0.1:9464) serves /metrics for Prometheus, /healthz (process up) and /readyz (fresh signed policy). Logs are structured JSON with request_id and the W3C trace_id from traceparent.
| Metric | Type | Labels |
|---|---|---|
decisions_total | counter | verdict, reason |
decision_latency_seconds | histogram | verdict |
manifest_age_seconds | gauge | |
manifest_revision | gauge | epoch |
manifest_expires_in_seconds | gauge | |
manifest_fresh | gauge | |
refresh_failures_total | counter | reason (network, signature, schema, stale) |
manifest_rejected_total | counter | code |
acks_total | counter | status, result |
receipts_sent_total | counter | |
receipts_dropped_total | counter | |
receipts_queued | gauge | |
connections_closed_total | counter | cause (max_stream_age, policy_stale, principal_denied) |
bundle_writes_total | counter | target, result |
Configuration
Every option can be set with a flag or in a YAML file (--config); flags win. The annotated reference is services/enforcer/config.example.yaml.
# mt-enforcer configuration reference. Every key is optional unless noted;
# command-line flags override the file. Load with --config FILE (or
# MT_ENFORCER_CONFIG). Durations use Go syntax: 500ms, 15s, 5m.
# proxy | envoy-grpc | forward-auth | lambda. Set by the subcommand, or here for `mt-enforcer run`.
mode: proxy
# Control plane URL. Default: the URL the gateway was paired with, else https://api.mutualtrust.io.
# Flag: --api. Environment: MT_API.
api: https://api.mutualtrust.io
# Directory holding gateway-state.json (the pairing: gateway credential, pinned
# policy key, gateway signing key). May be read-only (a Kubernetes Secret).
# Flag: --state. Environment: MT_ENFORCER_STATE_DIR. Default: ~/.mt/enforcer.
# The pairing can instead come from MT_ENFORCER_STATE_JSON (the file's content)
# or, on AWS Lambda, MT_ENFORCER_STATE_SECRET (a Secrets Manager ARN).
state_dir: /var/lib/mt-enforcer
# Writable directory for the policy high-water mark (applied.json) and the last
# verified manifest (manifest.jws). Default: state_dir. Flag: --data-dir.
data_dir: /var/lib/mt-enforcer
# Pair with the control plane on startup when state_dir has no pairing, showing
# the code on stderr. Flag: --pair.
pair: false
log:
level: info # debug | info | warn | error. Flag: --log-level
format: json # json | text. Flag: --log-format
admin:
# /metrics (Prometheus), /healthz (process up), /readyz (fresh signed policy).
# "" disables. Flag: --admin ("off" disables).
listen: 127.0.0.1:9464
bundle:
# Client CA bundle from the signed manifest, written by atomic rename on every change.
pem_path: "" # PEM file for NGINX, Traefik, HAProxy, Caddy, Kong. Flag: --bundle-out
envoy_sds_path: "" # Envoy file-based SDS resource. Flag: --envoy-sds-out
envoy_sds_secret_name: mt_client_ca
on_change: [] # argv run after a change, e.g. ["nginx", "-s", "reload"]. Flag: --on-bundle-change
on_change_delay: 0s
receipts:
flush_interval: 2s # how often queued receipts are delivered
batch_size: 100 # receipts per request (control plane maximum: 100)
queue_size: 10000 # oldest receipts are dropped beyond this (counted in receipts_dropped_total)
# mode: proxy (standalone mTLS reverse proxy)
proxy:
listen: 0.0.0.0:8443 # Flag: --listen
tls:
cert_file: /etc/mt-enforcer/tls/server.pem # your server certificate chain. Flag: --server-cert
key_file: /etc/mt-enforcer/tls/server-key.pem # Flag: --server-key
self_signed: false # development: generate server.pem in state_dir. Flag: --self-signed
min_version: "1.2" # "1.2" | "1.3"
# request: ask for a client certificate and deny without one (403 JSON problem).
# require: fail the TLS handshake without one. Flag: --client-auth
client_auth: request
peer:
# tls: this process terminates mTLS. forwarded: behind a load balancer that
# terminates mTLS and forwards the certificate (AWS ALB, Cloudflare). Flag: --peer-source
source: tls
format: "" # with forwarded: aws-alb | cloudflare | xfcc | rfc9440. Flag: --format
trusted_proxies: [] # with forwarded: the load balancer's source CIDRs (required). Flag: --trusted-proxies
origin:
url: http://127.0.0.1:9001 # required. Flag: --origin
ca_file: "" # CA for an https origin
cert_file: "" # client certificate for an mTLS origin
key_file: ""
headers: {} # static headers added to every proxied request (for example an origin shared secret)
timeout: 30s # response header timeout
max_stream_age: 5m # close client connections older than this. Flag: --max-stream-age
# mode: envoy-grpc (Envoy and Istio ext_authz)
envoy_grpc:
listen: 127.0.0.1:9191 # Flag: --listen
tls: # optional TLS for the gRPC listener
cert_file: "" # Flag: --server-cert
key_file: "" # Flag: --server-key
client_ca_file: "" # require Envoy to present a certificate from this CA. Flag: --client-ca
min_version: "1.2"
# mode: forward-auth (NGINX, ingress-nginx, Traefik, HAProxy, Kong, Caddy, Envoy HTTP ext_authz)
forward_auth:
listen: 127.0.0.1:9191 # Flag: --listen
# How the gateway forwards the certificate and the original request (required):
# nginx | ingress-nginx | traefik | haproxy | kong | caddy | xfcc. Flag: --format
format: nginx
trusted_proxies: ["127.0.0.0/8", "::1/128"] # gateway source CIDRs. Flag: --trusted-proxies
path_prefix: "" # stripped from the path (Envoy HTTP ext_authz path_prefix). Flag: --path-prefix
tls:
cert_file: ""
key_file: ""
client_ca_file: ""
min_version: "1.2"
# mode: lambda (AWS API Gateway authorizer)
lambda:
# auto: simple responses for HTTP API payload 2.0, IAM policies for REST API.
# iam: IAM policies for both (HTTP API with simple responses disabled). Flag: --response
response: auto