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.

Documentation menu

How enforcement works

  1. Pair. mt-enforcer pair generates 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

Enforcer modes
ModeUse it withWhat it does
proxyStandalone mTLS proxy, AWS ALBTerminates mTLS (or, behind a load balancer, reads the forwarded certificate), authorizes, and proxies allowed requests to your origin with the verified identity.
envoy-grpcEnvoy, IstioEnvoy external authorization gRPC service (envoy.service.auth.v3.Authorization/Check).
forward-authNGINX, ingress-nginx, Traefik, HAProxy, Kong, CaddyHTTP authorization endpoint the gateway calls for every request.
lambdaAWS API GatewayLambda authorizer for HTTP APIs and REST APIs with mutual TLS custom domains.
WorkerCloudflareA 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.

Install
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/enforcer

Header 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.

Prometheus metrics
MetricTypeLabels
decisions_totalcounterverdict, reason
decision_latency_secondshistogramverdict
manifest_age_secondsgauge
manifest_revisiongaugeepoch
manifest_expires_in_secondsgauge
manifest_freshgauge
refresh_failures_totalcounterreason (network, signature, schema, stale)
manifest_rejected_totalcountercode
acks_totalcounterstatus, result
receipts_sent_totalcounter
receipts_dropped_totalcounter
receipts_queuedgauge
connections_closed_totalcountercause (max_stream_age, policy_stale, principal_denied)
bundle_writes_totalcountertarget, 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.

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