# iron.sh > iron-proxy documentation ## Getting Help Three ways to reach us. ### Talk to Us For evaluations, deployment questions, or a second opinion on a policy you're writing, [talk to our founders](https://cal.com/matt-slipper-ironsh/15min). ### File a GitHub Issue Open an issue on the [iron-proxy GitHub repository](https://github.com/ironsh/iron-proxy/issues). Include: * Your iron-proxy version (run `iron-proxy --version`). * Whether you are using a Docker image or a standalone binary. * Details about your deployment: cloud provider, orchestration tool, and network configuration. * Steps to reproduce the problem, along with any relevant logs or CLI output. ### Email Support Email [support@iron.sh](mailto\:support@iron.sh) with the same information listed above. ## Quickstart Run iron-proxy locally with Docker Compose. By the end, you have a running proxy that allowlists specific hosts, blocks everything else, and swaps proxy tokens for real secrets. The setup uses the [docker-compose example](https://github.com/ironsh/iron-proxy/tree/main/examples/docker-compose) from the iron-proxy repo. ### Prerequisites * Docker and Docker Compose * Git :::steps #### Clone the Repo ```sh git clone https://github.com/ironsh/iron-proxy.git cd iron-proxy/examples/docker-compose ``` #### Start the Proxy and Demo Client ```sh docker compose up ``` This starts two containers on a shared bridge network: * **proxy**: builds iron-proxy from source, generates a CA certificate on startup, and listens on `172.20.0.2` * **client**: an Alpine container that uses the proxy's DNS and runs a series of demo requests #### Watch the Output The client runs five requests that demonstrate core functionality: **1. Allowed request.** `httpbin.org` is in the allowlist, so the request succeeds: ``` > curl https://httpbin.org/get ``` **2. Blocked request.** `example.com` is not in the allowlist: ``` > curl https://example.com/ # Returns 403 Forbidden ``` **3. Secret swap in Authorization header.** The client sends a proxy token, and iron-proxy replaces it with the real key before forwarding: ``` > curl -H "Authorization: Bearer proxy-openai-abc123" https://httpbin.org/headers # httpbin echoes back the real OPENAI_API_KEY value ``` **4. Secret swap in custom header.** `INTERNAL_TOKEN` is configured to match all headers: ``` > curl -H "X-Internal: proxy-internal-tok" https://httpbin.org/headers ``` **5. Secret swap in query parameter.** Query parameters are always scanned: ``` > curl "https://httpbin.org/get?token=proxy-openai-abc123&q=hello" ``` #### Check the Audit Logs View the proxy's structured JSON logs: ```sh docker compose logs proxy ``` Each request produces a log entry like: ```json { "host": "httpbin.org", "method": "GET", "path": "/headers", "action": "allow", "status_code": 200, "duration_ms": 142, "request_transforms": [ { "name": "allowlist", "action": "continue" }, { "name": "secrets", "action": "continue", "annotations": { "swapped": [ { "secret": "OPENAI_API_KEY", "locations": ["header:Authorization"] } ] } } ] } ``` Blocked requests include a `rejected_by` field and log at WARN level. ::: ### What Just Happened #### Docker Setup The Compose file creates a bridge network (`172.20.0.0/24`) with two services and a shared volume for the CA certificate: ```yaml services: proxy: build: context: ../.. dockerfile: examples/docker-compose/Dockerfile environment: - OPENAI_API_KEY=sk-real-openai-key-do-not-share - INTERNAL_TOKEN=real-internal-secret-value volumes: - certs:/certs networks: demo: ipv4_address: 172.20.0.2 client: image: alpine:latest depends_on: - proxy dns: - 172.20.0.2 volumes: - ./client.sh:/demo/client.sh:ro - certs:/certs:ro entrypoint: ["/bin/sh", "/demo/client.sh"] networks: demo: ipv4_address: 172.20.0.4 volumes: certs: networks: demo: driver: bridge ipam: config: - subnet: 172.20.0.0/24 ``` The key wiring: * **`dns: [172.20.0.2]`** on the client points all DNS lookups at the proxy. The proxy's DNS server returns its own IP for every lookup, so all HTTP/HTTPS traffic routes through it. * **`environment`** on the proxy holds real secrets. The client never has access to these values. * **`certs` volume** is shared between both services. The proxy generates a CA cert on startup and writes it here. The client reads it so it can trust the proxy's TLS certificates. #### Proxy Config The proxy is configured via [`proxy.yaml`](https://github.com/ironsh/iron-proxy/blob/main/examples/docker-compose/proxy.yaml): ```yaml # Built-in DNS server. Returns proxy_ip for all lookups so # all outbound traffic routes through the proxy. dns: listen: ":53" proxy_ip: "172.20.0.2" proxy: http_listen: ":80" https_listen: ":443" # CA cert used to mint leaf certificates on the fly for TLS # interception. The client trusts this CA via the shared volume. tls: ca_cert: "/certs/ca.crt" ca_key: "/certs/ca.key" # Transforms run in order on every request. transforms: # Only requests to these domains are allowed. # Everything else gets a 403. - name: allowlist config: domains: - "httpbin.org" # Read real secret values from the proxy container's environment. # Each secret maps a proxy token to an env var. When the proxy # sees a token in a request, it swaps in the real value before # forwarding upstream. - name: secrets config: secrets: - source: type: env var: OPENAI_API_KEY # env var on the proxy container replace: proxy_value: "proxy-openai-abc123" # token the client sends match_headers: ["Authorization"] # only scan this header match_body: false rules: - host: "httpbin.org" - source: type: env var: INTERNAL_TOKEN replace: proxy_value: "proxy-internal-tok" match_headers: [] # empty list = scan all headers match_body: false rules: - host: "httpbin.org" log: level: "info" ``` ## AI Coding Agents An iron-proxy in front of an AI coding agent allowlists the model APIs and source hosts the agent needs, holds the model provider keys, and logs every request including the blocked ones. ### A Starting Config A minimal setup that lets the agent talk to model APIs and read public source, with credentials held at the boundary. ```yaml transforms: - name: allowlist config: domains: - "api.anthropic.com" - "api.openai.com" - "registry.npmjs.org" - "api.github.com" - "raw.githubusercontent.com" - name: secrets config: secrets: - source: { type: env, var: ANTHROPIC_API_KEY } replace: proxy_value: "proxy-anthropic-key" match_headers: ["x-api-key", "Authorization"] rules: - host: "api.anthropic.com" - source: { type: env, var: OPENAI_API_KEY } replace: proxy_value: "proxy-openai-key" match_headers: ["Authorization"] rules: - host: "api.openai.com" ``` The agent's environment holds `proxy-anthropic-key` and `proxy-openai-key`. The real keys never leave the proxy process. ### Add MCP Interception If the agent uses MCP servers, [MCP Interception](/policies/mcp-interception) gives you per-tool allowlists: deny `shell.exec` to one server, allow read-only filesystem tools on another, log every tool call with its arguments. ### Deploy It * [Kubernetes](/deploy/kubernetes): sidecar in the agent's pod. * [Daytona](/deploy/daytona): pre-baked into a Daytona workspace image. * [Bare metal](/deploy/bare-metal): systemd unit alongside a long-running agent process. * [Freestyle](/deploy/freestyle): bootstrap during sandbox setup. ### Related * [LLM Judge](/policies/llm-judge): semantic policy on top of the network allowlist. * [Audit log export](/guides/otel-export): pipe every agent request to your observability stack. * [Quickstart](/quickstart): see the allow, block, and secret-swap loop end-to-end. ## CI/CD Egress Control An iron-proxy in front of a CI runner allowlists the registries and APIs the build needs, holds the registry tokens and credentials the build uses, and logs every request with method, host, path, status, and the policy that decided. ### A Starting Config Size the allowlist to the dependencies the build actually pulls. Add credential injection for the registries and APIs the build calls. ```yaml transforms: - name: allowlist config: domains: - "registry.npmjs.org" - "*.pypi.org" - "proxy.golang.org" - "api.github.com" - "ghcr.io" - name: secrets config: secrets: - source: { type: env, var: NPM_TOKEN } replace: proxy_value: "proxy-npm-token" match_headers: ["Authorization", "//npm-auth-token"] rules: - host: "registry.npmjs.org" - source: { type: env, var: GITHUB_TOKEN } replace: proxy_value: "proxy-gh-token" match_headers: ["Authorization"] rules: - host: "api.github.com" - host: "ghcr.io" ``` The build references `proxy-npm-token` and `proxy-gh-token` as if they were the real tokens. ### Pick Your Runner * [GitHub Actions](/deploy/github-actions): single-step install with the `ironsh/iron-proxy-action` action. Includes the per-job summary table. * [Kubernetes](/deploy/kubernetes): sidecar pattern for runner pods (Argo, Tekton, GitLab Runner on K8s, Jenkins agents). * [Amazon ECS](/deploy/ecs): sidecar container in the same task definition as the runner. * [Bare metal](/deploy/bare-metal): systemd unit on self-hosted runners. ### Related * [Quickstart](/quickstart): local Docker Compose demo of allow, block, and secret-swap. * [Static Secrets](/credential-proxying/static-secrets): full reference for the `secrets` transform. * [LLM Judge](/policies/llm-judge): add semantic filtering on top of the allowlist for AI-driven builds. ## Sandboxed Code Execution An iron-proxy in front of a sandbox allowlists the hosts the sandboxed code can reach, holds any credentials the platform exposes to that code, and logs every outbound request with the policy that decided. ### A Starting Config ```yaml transforms: - name: allowlist config: domains: - "api.anthropic.com" - "api.openai.com" - "pypi.org" - "*.pypi.org" - "files.pythonhosted.org" - name: secrets config: secrets: - source: { type: env, var: OPENAI_API_KEY } replace: proxy_value: "proxy-openai-key" match_headers: ["Authorization"] rules: - host: "api.openai.com" log: level: "info" ``` The sandbox process gets `proxy-openai-key` in its environment. ### Deploy It * [Kubernetes](/deploy/kubernetes): one proxy per pod for per-sandbox isolation, or one shared proxy with tenant-scoped policies. * [Amazon ECS](/deploy/ecs): sidecar in the sandbox task definition. * [Daytona](/deploy/daytona): pre-baked into the sandbox image. * [Freestyle](/deploy/freestyle): bootstrap during sandbox provisioning. ### Multi-Tenant Patterns When many sandboxes need different policies, the [Control Plane](/control-plane/overview) lets you author centrally and roll to fleets of proxies. [Self-hosted](/control-plane/self-hosted) is available when the policy authority has to stay in your VPC. ### Related * [MCP Interception](/policies/mcp-interception): per-tool policy for sandboxes that expose MCP servers. * [Audit log export](/guides/otel-export): pipe per-sandbox logs to your SIEM or warehouse. * [Quickstart](/quickstart): see the loop end-to-end in a local Compose setup. ## Configuration iron-proxy is configured via a single YAML file, passed at startup with the `-config` flag: ```sh iron-proxy -config /etc/iron-proxy/proxy.yaml ``` Below is a complete reference for every configuration option. ### Full Example ```yaml dns: listen: ":53" proxy_ip: "172.20.0.2" upstream_resolver: "8.8.8.8:53" passthrough: - "*.internal.corp" records: - name: "custom.local" type: A value: "10.0.0.5" proxy: http_listen: ":80" https_listen: ":443" tunnel_listen: ":1080" max_request_body_bytes: 1048576 max_response_body_bytes: 0 upstream_response_header_timeout: "30s" upstream_deny_cidrs: - "169.254.169.254/32" - "fd00:ec2::254/128" - "127.0.0.0/8" - "::1/128" tls: mode: "mitm" ca_cert: "/certs/ca.crt" ca_key: "/certs/ca.key" cert_cache_size: 1000 leaf_cert_expiry_hours: 72 transforms: - name: allowlist config: domains: - "registry.npmjs.org" cidrs: - "10.0.0.0/8" rules: - host: "api.openai.com" methods: ["POST"] paths: ["/v1/*"] - host: "*.anthropic.com" methods: ["POST"] paths: ["/v1/messages", "/v1/complete"] - name: secrets config: secrets: - source: type: env var: OPENAI_API_KEY inject: header: "Authorization" formatter: "Bearer {{ .Value }}" rules: - host: "api.openai.com" methods: ["POST"] paths: ["/v1/*"] - source: type: aws_sm secret_id: "arn:aws:secretsmanager:us-west-1:123456789:secret:anthropic-key" region: "us-west-1" ttl: 5m replace: proxy_value: "pk-proxy-anthropic-xyz" match_headers: ["x-api-key"] require: true rules: - host: "api.anthropic.com" - source: type: aws_ssm name: "/myapp/openai-key" region: "us-east-1" ttl: 15m replace: proxy_value: "pk-proxy-openai-param" match_headers: ["Authorization"] rules: - host: "api.openai.com" - name: annotate config: annotations: - rules: - host: "api.openai.com" methods: ["POST"] paths: ["/v1/*"] headers: ["x-request-id"] - name: grpc config: name: "policy-engine" target: "localhost:9500" send_request_body: true send_response_body: true rules: - host: "api.openai.com" methods: ["POST"] paths: ["/v1/*"] tls: enabled: true ca_cert: "/etc/iron-proxy/grpc-ca.pem" cert: "/etc/iron-proxy/grpc-client.pem" key: "/etc/iron-proxy/grpc-client-key.pem" - name: judge config: name: "github-write-guard" fallback: "deny" timeout: "8s" rules: - host: "api.github.com" methods: ["POST", "PATCH", "DELETE", "PUT"] provider: type: "anthropic" model: "claude-haiku-4-5-20251001" api_key_env: "ANTHROPIC_API_KEY" prompt: | Allow writes to the repository under review. Deny writes to user settings, billing, or any other repository. - name: header_allowlist config: headers: - "Authorization" - "Content-Type" - "User-Agent" - "Accept" - "/^X-Trace-.*$/" rules: - host: "api.openai.com" mcp: error: code: -32001 message: "blocked by iron-proxy policy" servers: - name: github rules: - host: "mcp.github.com" paths: ["/mcp", "/mcp/*"] tools: - name: "search_repositories" - name: "create_issue" when: - path: "owner" equals: "ironsh" - path: "repo" in: ["iron-proxy"] management: listen: "127.0.0.1:9092" api_key_env: "IRON_MANAGEMENT_API_KEY" metrics: listen: ":9090" log: level: "info" ``` ### `dns` Configures the built-in DNS server. The DNS server returns `proxy_ip` for all lookups so that outbound traffic routes through the proxy. | Field | Type | Default | Description | | ------------------- | --------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `listen` | string | `":53"` | Address and port the DNS server binds to. | | `proxy_ip` | string | **required** | IP address where iron-proxy is running. All DNS responses resolve to this IP. | | `upstream_resolver` | string | OS default | Upstream DNS resolver address (e.g., `"8.8.8.8:53"`). When set, both passthrough DNS queries and upstream HTTP connections resolve via this server instead of the OS default. Useful when iron-proxy owns the system DNS. | | `passthrough` | string\[] | `[]` | Domain glob patterns that are forwarded to the upstream resolver instead of being intercepted. Useful for internal DNS names that should not route through the proxy. | | `records` | object\[] | `[]` | Static DNS records. These take precedence over interception and passthrough. See below. | #### `dns.records[]` | Field | Type | Description | | ------- | ------ | ---------------------------------------------------------------------- | | `name` | string | Domain name for the record. | | `type` | string | Record type: `A` or `CNAME`. | | `value` | string | IP address (for `A` records) or target hostname (for `CNAME` records). | ### `proxy` Configures the HTTP/HTTPS proxy listeners. | Field | Type | Default | Description | | ---------------------------------- | --------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `http_listen` | string | `":80"` | Address and port for HTTP traffic. | | `https_listen` | string | `":443"` | Address and port for HTTPS traffic. | | `tunnel_listen` | string | (disabled) | Address and port for the CONNECT/SOCKS5 tunnel listener. Accepts both HTTP CONNECT and SOCKS5 requests. See the [SOCKS5 and CONNECT tunnels guide](/guides/socks5-connect) for details. | | `max_request_body_bytes` | integer | `1048576` (1 MiB) | Maximum request body size that the proxy will buffer. Bodies are only buffered when a transform needs to inspect them. | | `max_response_body_bytes` | integer | `0` (unlimited) | Maximum response body size that the proxy will buffer. Set to `0` to disable the limit. | | `upstream_response_header_timeout` | duration | `"30s"` | Maximum time to wait for an upstream to begin sending response headers. Raise this for slow upstreams (e.g., long-running LLM calls). Invalid or non-positive durations are rejected at startup. | | `upstream_deny_cidrs` | string\[] | IMDS + loopback (see below) | CIDR ranges the proxy will refuse to dial regardless of allowlist contents. Enforced after DNS resolution, so hostnames that resolve into a denied range are refused before TCP connect. Set to `[]` to opt out. | #### `upstream_deny_cidrs` Defaults When `upstream_deny_cidrs` is unset, iron-proxy blocks cloud instance metadata and loopback by default: * `169.254.169.254/32` (AWS / GCP / Azure IPv4 IMDS) * `fd00:ec2::254/128` (AWS IPv6 IMDS) * `127.0.0.0/8` (IPv4 loopback) * `::1/128` (IPv6 loopback) The defaults intentionally omit RFC1918 ranges, since many iron-proxy deployments target private corporate networks. Provide an explicit list to override the defaults entirely, or an empty list to opt out: ```yaml proxy: upstream_deny_cidrs: - "169.254.169.254/32" - "127.0.0.0/8" ``` If a workload legitimately needs to talk to IMDS or loopback through the proxy, override the list before upgrading or the proxy will refuse those connections. ### `tls` Configures how iron-proxy handles HTTPS traffic. Two modes are supported: `mitm` (the default), which terminates TLS using a CA you provide and mints leaf certificates on the fly, and `sni-only`, which passes TLS through without termination. | Field | Type | Default | Description | | ------------------------ | ------- | ------------------- | -------------------------------------------------------------------------------------------- | | `mode` | string | `"mitm"` | TLS handling mode. One of `mitm` or `sni-only`. See [TLS modes](#tls-modes) below. | | `ca_cert` | string | required for `mitm` | Path to the CA certificate file (PEM format). Not used in `sni-only` mode. | | `ca_key` | string | required for `mitm` | Path to the CA private key file (PEM format). Not used in `sni-only` mode. | | `cert_cache_size` | integer | `1000` | Number of generated leaf certificates to keep in the LRU cache. Not used in `sni-only` mode. | | `leaf_cert_expiry_hours` | integer | `72` | Validity duration (in hours) for generated leaf certificates. Not used in `sni-only` mode. | #### TLS Modes **`mitm`** (default): iron-proxy terminates the client TLS connection, inspects the decrypted request, and opens a new TLS connection to the upstream server. Clients must trust iron-proxy's CA certificate. This is the only mode that lets transforms see request methods, paths, headers, and bodies. **`sni-only`**: iron-proxy peeks at the TLS ClientHello SNI and TCP-passthroughs the connection to the upstream without terminating TLS. Clients do not need to trust a proxy CA. The transform pipeline still runs with a host-only synthetic request: method, path, headers, and body are empty, so host-based allowlist rules are the only things that can match. Body-inspecting transforms like `secrets` and `grpc` still run but have nothing to act on. The CONNECT/SOCKS5 tunnel's TLS branch also switches to passthrough in `sni-only` mode. ```yaml tls: mode: "sni-only" ``` Use `sni-only` when you need host-level egress control but cannot distribute a CA certificate to workloads. Use `mitm` when you need secret injection, body inspection, or method/path allowlists. ### `transforms` An ordered array of transforms that run on every request. All transforms must pass for the request to be forwarded upstream. Transforms execute in the order they appear in the configuration. Each transform has a `name` and a `config` object. The available transforms are documented below. #### `allowlist` Controls which destinations are reachable through the proxy. Requests to destinations not in the allowlist receive an HTTP 403 response. There are two ways to specify allowed destinations: flat lists (`domains` and `cidrs`) that allow all methods and paths, and `rules` that support method and path restrictions. Both can be used together in the same allowlist. | Field | Type | Default | Description | | --------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `domains` | string\[] | `[]` | Hostname glob patterns to allow (e.g., `registry.npmjs.org`, `*.anthropic.com`). A bare `"*"` matches any host as a catch-all. All methods and paths are permitted. | | `cidrs` | string\[] | `[]` | CIDR ranges to allow (e.g., `10.0.0.0/8`). All methods and paths are permitted. | | `rules` | object\[] | `[]` | Rules with optional method and path restrictions. See below. | | `warn` | boolean | `false` | When `true`, violations are logged but not blocked. Useful for rolling out allowlists incrementally. | ##### `allowlist.rules[]` Each rule matches a single host or CIDR, with optional method and path filters. A request is allowed if it matches any rule (or any flat `domains`/`cidrs` entry). | Field | Type | Default | Description | | --------- | --------- | ------- | ----------------------------------------------------------------------------------------------------------------- | | `host` | string | | Hostname glob pattern. A bare `"*"` matches any host. Mutually exclusive with `cidr`. | | `cidr` | string | | CIDR range. Mutually exclusive with `host`. | | `methods` | string\[] | all | HTTP methods to allow (e.g., `["GET", "POST"]`). Omit or set to `["*"]` to allow all methods. | | `paths` | string\[] | all | Path patterns to allow (e.g., `["/v1/*"]`). Must start with `/`. Supports `*` wildcards. Omit to allow all paths. | #### `secrets` Injects or replaces secret values at the egress boundary so that real credentials are never exposed to sandboxed workloads. Each secret declares its own source and either an `inject` or `replace` block. See the [Static Secrets reference](/credential-proxying/static-secrets) for a full overview of inject and replace modes, secret sources, and credential rotation. The fields below are the canonical schema. | Field | Type | Default | Description | | --------- | --------- | ------- | ---------------------------------- | | `secrets` | object\[] | `[]` | List of secret entries. See below. | ##### `secrets.secrets[]` | Field | Type | Default | Description | | --------- | --------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `source` | object | **required** | Where to read the secret value. Contains a `type` field (`env`, `aws_sm`, `aws_ssm`, `1password`, or `1password_connect`) plus type-specific fields. See [sources](#secret-sources) below. | | `inject` | object | | Inject the secret onto matching requests unconditionally. See [inject mode](#inject-mode). Mutually exclusive with `replace`. | | `replace` | object | | Replace a proxy token with the real value. See [replace mode](#replace-mode). Mutually exclusive with `inject`. | | `rules` | object\[] | `[]` | Restrict this secret to specific destinations. Uses the same format as [`allowlist.rules[]`](#allowlistrules). If empty, the secret applies to all destinations. | ##### Secret Sources Every source supports an optional `json_key` field. When set, the resolved value is parsed as JSON and the named field is extracted before use. This works with all source types (`env`, `aws_sm`, `aws_ssm`, `1password`, `1password_connect`) and is useful for pulling individual fields out of a single shared JSON secret. ##### `env` Read the secret from an environment variable on the iron-proxy process. | Field | Type | Description | | ---------- | ------ | ------------------------------------------------------------------- | | `type` | string | Must be `env`. | | `var` | string | Environment variable name containing the real secret value. | | `json_key` | string | Optional. When set, parse the value as JSON and extract this field. | ```yaml source: type: env var: OPENAI_API_KEY ``` ##### `aws_sm` Read the secret from AWS Secrets Manager. The value is cached and refreshed in the background based on the configured TTL. | Field | Type | Default | Description | | ----------- | -------- | ---------------- | ----------------------------------------------------------------- | | `type` | string | | Must be `aws_sm`. | | `secret_id` | string | **required** | Secret ARN or name in AWS Secrets Manager. | | `region` | string | AWS SDK default | AWS region where the secret is stored. | | `json_key` | string | | When set, parse the fetched value as JSON and extract this field. | | `ttl` | duration | `0` (no refresh) | Re-fetch interval. Set to `0` to read the value once at startup. | ```yaml source: type: aws_sm secret_id: "arn:aws:secretsmanager:us-west-1:123456789:secret:my-key" region: "us-west-1" ttl: 10m ``` ##### `aws_ssm` Read the secret from AWS Systems Manager Parameter Store. Like `aws_sm`, values are cached and refreshed in the background based on the TTL. | Field | Type | Default | Description | | ----------------- | -------- | ---------------- | ----------------------------------------------------------------- | | `type` | string | | Must be `aws_ssm`. | | `name` | string | **required** | Parameter name or ARN. | | `region` | string | AWS SDK default | AWS region where the parameter is stored. | | `with_decryption` | boolean | `true` | Decrypt `SecureString` parameters. | | `json_key` | string | | When set, parse the fetched value as JSON and extract this field. | | `ttl` | duration | `0` (no refresh) | Re-fetch interval. Set to `0` to read the value once at startup. | ```yaml source: type: aws_ssm name: "/myapp/api-key" region: "us-east-1" with_decryption: true ttl: 15m ``` iron-proxy uses the standard AWS credential chain (environment variables, instance profile, ECS task role, etc.) to authenticate with AWS. ##### `1password` Read the secret from 1Password using a service account token. Like the AWS sources, values are cached and refreshed in the background based on the TTL. For most deployments, prefer [`1password_connect`](#1password_connect): the hosted SDK service applies per-account rate limits that can stall request handling under load. | Field | Type | Default | Description | | ------------ | -------- | -------------------------- | ----------------------------------------------------------------------- | | `type` | string | | Must be `1password`. | | `secret_ref` | string | **required** | 1Password reference using the `op://vault/item/[section/]field` syntax. | | `token_env` | string | `OP_SERVICE_ACCOUNT_TOKEN` | Environment variable holding the 1Password service account token. | | `json_key` | string | | When set, parse the fetched value as JSON and extract this field. | | `ttl` | duration | `0` (no refresh) | Re-fetch interval. Set to `0` to read the value once at startup. | ```yaml source: type: 1password secret_ref: "op://Engineering/OpenAI/credential" token_env: OP_SERVICE_ACCOUNT_TOKEN ttl: 15m ``` ##### `1password_connect` Read the secret from a 1Password Connect server running in your own infrastructure. This is the recommended 1Password integration: Connect runs locally and avoids the per-account rate limits that the hosted SDK enforces. Values are cached and refreshed in the background based on the TTL. | Field | Type | Default | Description | | ------------ | -------- | ------------------ | ----------------------------------------------------------------------- | | `type` | string | | Must be `1password_connect`. | | `secret_ref` | string | **required** | 1Password reference using the `op://vault/item/[section/]field` syntax. | | `host_env` | string | `OP_CONNECT_HOST` | Environment variable holding the Connect server URL. | | `token_env` | string | `OP_CONNECT_TOKEN` | Environment variable holding the Connect API token. | | `json_key` | string | | When set, parse the fetched value as JSON and extract this field. | | `ttl` | duration | `0` (no refresh) | Re-fetch interval. Set to `0` to read the value once at startup. | ```yaml source: type: 1password_connect secret_ref: "op://Engineering/OpenAI/credential" host_env: OP_CONNECT_HOST token_env: OP_CONNECT_TOKEN ttl: 15m ``` ##### Inject Mode In inject mode, the proxy unconditionally sets a header or query parameter on every request that matches the secret's rules. The workload never sees or sends any credential. This is useful when sandboxed workloads should have no knowledge of credentials at all. | Field | Type | Description | | ------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `header` | string | Header name to set on matching requests. Sent upstream with the exact casing written here (HTTP/1.x only: HTTP/2 lowercases header names regardless). Mutually exclusive with `query_param`. | | `query_param` | string | Query parameter name to set on matching requests. Mutually exclusive with `header`. | | `formatter` | string | Go template for the header value. Receives `.Value` (the resolved secret) and a `base64` helper. Not used with `query_param`. | The `formatter` field supports Go templates. Use `{{ .Value }}` to insert the raw secret. The `base64` helper concatenates and base64-encodes its arguments: ```yaml # Set Authorization: Bearer inject: header: "Authorization" formatter: "Bearer {{ .Value }}" # Set a query parameter (no formatter needed) inject: query_param: "key" ``` ##### Replace Mode In replace mode, the workload sends a proxy token that the proxy swaps for the real value before forwarding upstream. | Field | Type | Default | Description | | --------------- | --------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `proxy_value` | string | **required** | Token that the sandboxed environment sends. The proxy replaces this with the real value before forwarding. | | `match_headers` | string\[] | `[]` | Header names to scan for the proxy token. An empty list scans all headers. Each entry is either a literal header name (case-insensitive) or a `/regex/` pattern compiled at config time as a case-insensitive regular expression matched against canonical header names. Literal and regex entries can be mixed. Headers are forwarded upstream with the exact casing written here (HTTP/1.x only: HTTP/2 lowercases header names regardless). | | `match_body` | boolean | `false` | When `true`, scan the request body for the proxy token. | | `match_path` | boolean | `false` | When `true`, scan `req.URL.Path` for the proxy token and swap in the resolved secret. Off by default because URL paths often appear in access logs on either side of the proxy. | | `match_query` | boolean | `false` | When `true`, scan the URL query string for the proxy token and swap in the resolved secret. Off by default because query strings often appear in access logs. | | `require` | boolean | `false` | When `true`, requests matching a configured rule are rejected with HTTP 403 if the proxy token is not present in any scanned location. Prevents workloads from bypassing secret management. | The `proxy_value`, `match_headers`, `match_body`, `match_path`, `match_query`, and `require` fields may also be set at the top level of a secret entry for backwards compatibility. New configurations should use the `replace` block. #### `gcp_auth` Mints short-lived GCP OAuth2 access tokens from a service account keyfile and injects them as `Authorization: Bearer` on matching requests. Token minting, caching, and refresh are handled automatically. Requires MITM mode. iron-proxy also stubs Google's OAuth2 token endpoints (`oauth2.googleapis.com/token` and the GCE/GKE metadata server's service-account token endpoints) so client SDKs that complete their own token dance reach the proxy with a placeholder token. The real token is minted separately and swapped onto the upstream API call. See the [GCP Service Accounts reference](/credential-proxying/gcp-auth) for a full overview, including keyfile sourcing, domain-wide delegation, and metadata server stubbing. | Field | Type | Default | Description | | ---------------------- | --------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `keyfile_path` | string | | Path to a Google service account JSON keyfile on disk. | | `keyfile` | object | | Secret source that resolves to the keyfile JSON. Uses the same shape as a [secret source](#secret-sources). | | `credentials_provider` | object | | Resolves credentials through the Google Cloud default chain (GKE Workload Identity, `GOOGLE_APPLICATION_CREDENTIALS`, Workload Identity Federation). See [Workload Identity](/credential-proxying/gcp-auth#workload-identity). | | `subject` | string | | Workspace user email to impersonate via domain-wide delegation. When set, the minted token acts as the subject rather than the service account. Incompatible with `credentials_provider`. | | `scopes` | string\[] | **required** | OAuth2 scopes to request. Baked into the minted token. | | `rules` | object\[] | `[]` | Restrict the transform to specific destinations. Uses the same format as [`allowlist.rules[]`](#allowlistrules). | Exactly one of `keyfile_path`, `keyfile`, or `credentials_provider` must be set. `credentials_provider` accepts `{type: workload_identity}`. The proxy holds the rotating credentials minted by the cloud SDK; the workload runs against the stubbed metadata server as usual. ```yaml - name: gcp_auth config: keyfile: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:gcp-sa-key" subject: "user@workspace.example.com" scopes: - "https://www.googleapis.com/auth/cloud-platform" rules: - host: "*.googleapis.com" ``` #### `oauth_token` Mints short-lived OAuth2 access tokens and injects them as `Authorization: Bearer` on matching requests. Each entry under `tokens` declares a grant type, its credential fields, and the hosts it applies to. Token exchange, caching, refresh, and single-flight deduplication are handled automatically. Requires MITM mode. If token minting fails the request is rejected with HTTP 502. Each configured `token_endpoint` is also stubbed, so sandboxed client SDKs can complete their own OAuth2 handshake against the proxy with a placeholder token while the proxy injects the real one upstream. See the [OAuth2 Token Injection reference](/credential-proxying/oauth-token) for a full overview, including per-vendor recipes (DocuSign, Salesforce, Box, Zoom, Gmail, AlphaSense) and credential rotation behavior. | Field | Type | Default | Description | | -------- | --------- | ------------ | ----------------------------------------------------------- | | `tokens` | object\[] | **required** | List of token entries. At least one is required. See below. | ##### `oauth_token.tokens[]` | Field | Type | Default | Description | | ------------------------ | --------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `grant` | string | **required** | One of `refresh_token`, `client_credentials`, `password`, `jwt_bearer`. | | `token_endpoint` | string | **required** | URL the proxy POSTs to in order to exchange credentials for an access token. Also stubbed for client-side token requests. | | `scopes` | string\[] | `[]` | OAuth2 scopes requested at the token endpoint. | | `rules` | object\[] | **required** | Destinations this entry applies to. Uses the same format as [`allowlist.rules[]`](#allowlistrules). At least one rule is required. | | `header` | string | `Authorization` | Header to set on matching requests. | | `value_prefix` | string | `Bearer ` | Prefix prepended to the token in the injected header value. | | `token_endpoint_headers` | object | | Map of header name to secret source. Each resolved value is sent on the token POST itself, for vendors that require an API key alongside the standard form-body client auth. Header casing is preserved on the wire. | Required credential fields depend on the grant: | Grant | Required credentials | Optional credentials | Other required | | -------------------- | ----------------------------------- | -------------------- | -------------- | | `refresh_token` | `refresh_token`, `client_id` | `client_secret` | | | `client_credentials` | `client_id`, `client_secret` | | | | `password` | `username`, `password`, `client_id` | `client_secret` | | | `jwt_bearer` | `issuer`, `subject`, `private_key` | `private_key_id` | `audience` | Each credential field is a discrete [secret source](#secret-sources). `private_key` for `jwt_bearer` must resolve to a PEM-encoded RSA private key; `private_key_id`, when set, is emitted as the JWT `kid` header. For Google service-account auth, use the [`gcp_auth`](#gcp_auth) transform instead: it wraps the same JWT-bearer flow with Google's keyfile format. ```yaml - name: oauth_token config: tokens: - grant: refresh_token refresh_token: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:gsuite-oauth" json_key: "refresh_token" client_id: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:gsuite-oauth" json_key: "client_id" client_secret: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:gsuite-oauth" json_key: "client_secret" token_endpoint: "https://oauth2.googleapis.com/token" scopes: - "https://www.googleapis.com/auth/gmail.readonly" rules: - host: "gmail.googleapis.com" - grant: jwt_bearer issuer: {type: env, var: DOCUSIGN_INTEGRATION_KEY} subject: {type: env, var: DOCUSIGN_USER_GUID} private_key: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:docusign-private-key" audience: "account.docusign.com" token_endpoint: "https://account.docusign.com/oauth/token" scopes: ["signature", "impersonation"] rules: - host: "*.docusign.net" ``` #### `aws_auth` Re-signs inbound AWS SigV4 requests with real credentials. The workload's AWS SDK signs with placeholders; iron-proxy reads the region and service from the inbound credential scope and re-signs with real credentials drawn from any [secret source](#secret-sources). One config entry covers every AWS service the client speaks to. Requires MITM mode. See the [AWS Request Signing reference](/credential-proxying/aws-auth) for a full overview, including scope gating, body handling modes, and worked examples. | Field | Type | Default | Description | | ---------------------- | --------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `access_key_id` | object | | Secret source resolving to the real AWS access key ID. Required unless `credentials_provider` is set. | | `secret_access_key` | object | | Secret source resolving to the real AWS secret access key. Required unless `credentials_provider` is set. | | `session_token` | object | | Secret source resolving to a session token (STS, assumed roles). Omit for long-lived IAM users. | | `credentials_provider` | object | | Resolves credentials through the AWS SDK default chain (IRSA, EKS Pod Identity, IMDSv2). Mutually exclusive with `access_key_id`/`secret_access_key`. Accepts `{type: workload_identity, region?: string}`. See [Workload Identity](/credential-proxying/aws-auth#workload-identity). | | `allowed_regions` | string\[] | any region | Allowlist of AWS regions. Requests with a credential scope outside this set are rejected with HTTP 403. | | `allowed_services` | string\[] | any service | Allowlist of AWS services. Requests with a credential scope outside this set are rejected with HTTP 403. | | `unsigned_payload` | boolean | `false` | Send `UNSIGNED-PAYLOAD` as the payload hash instead of reading and hashing the body. Required for S3 multipart and similar streaming uploads. | | `allow_chunked_body` | boolean | `false` | Sign chunked-encoding bodies without length verification. | | `rules` | object\[] | **required** | Destinations this transform applies to. Uses the same format as [`allowlist.rules[]`](#allowlistrules). At least one rule is required. | ```yaml - name: aws_auth config: access_key_id: {type: env, var: AWS_ACCESS_KEY_ID} secret_access_key: {type: env, var: AWS_SECRET_ACCESS_KEY} allowed_regions: ["us-east-1", "eu-west-1"] allowed_services: ["bedrock", "s3", "dynamodb"] rules: - host: "*.amazonaws.com" ``` #### `hmac_sign` Signs outbound requests with HMAC before forwarding them upstream. Computes a signature over a Go-template message derived from the request and injects the signature plus any auxiliary credentials into a configurable set of headers. The four signature enums (`algorithm`, `key_encoding`, `output_encoding`, `timestamp.format`) plus the message template cover most signing schemes. Requires MITM mode. See the [HMAC Request Signing reference](/credential-proxying/hmac-sign) for a full overview, including template variable details, body integrity rules, and worked examples. Because a truncated body would produce an invalid signature, the transform enforces body integrity: * Bodies whose buffered length falls short of `Content-Length` (truncated by `proxy.max_request_body_bytes`) are rejected with HTTP 413. * Chunked bodies are rejected with HTTP 400 unless `allow_chunked_body: true`. | Field | Type | Default | Description | | --------------------------- | --------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `timestamp.format` | string | **required** | One of `unix_seconds`, `unix_millis`, `unix_nanos`, `rfc3339`. | | `signature.algorithm` | string | **required** | HMAC hash. One of `sha256`, `sha512`, `sha1`. | | `signature.key_encoding` | string | **required** | Encoding of the HMAC key before keying. One of `raw`, `base64`, `hex`. | | `signature.output_encoding` | string | **required** | Encoding of the computed signature. One of `base64`, `hex`. | | `signature.message` | string | **required** | Go template for the signed message. Available fields: `.Timestamp`, `.Method`, `.Path`, `.PathWithQuery`, `.Query`, `.Host`, `.Body`. | | `credentials` | object | **required** | Map of credential name to [secret source](#secret-sources). Must include `secret` (the HMAC key). Other entries are addressable from header templates as `.Credentials.`. | | `headers` | object\[] | **required** | Ordered list of `{name, value}` entries to inject. `value` is a Go template with `.Timestamp`, `.Signature`, and `.Credentials.` available. Header casing is preserved on the wire. | | `allow_chunked_body` | boolean | `false` | Opt in to signing chunked-encoding bodies. | | `rules` | object\[] | **required** | Destinations this transform applies to. At least one rule is required. | ```yaml - name: hmac_sign config: timestamp: format: unix_seconds signature: algorithm: sha256 key_encoding: base64 output_encoding: base64 message: "{{.Timestamp}}{{.Method}}{{.PathWithQuery}}{{.Body}}" credentials: key: {type: env, var: FALCONX_API_KEY} secret: {type: env, var: FALCONX_SECRET} passphrase: {type: env, var: FALCONX_PASSPHRASE} headers: - {name: "FX-ACCESS-KEY", value: "{{.Credentials.key}}"} - {name: "FX-ACCESS-SIGN", value: "{{.Signature}}"} - {name: "FX-ACCESS-TIMESTAMP", value: "{{.Timestamp}}"} - {name: "FX-ACCESS-PASSPHRASE", value: "{{.Credentials.passphrase}}"} rules: - host: "api.falconx.io" ``` #### `body_capture` Records decoded request bodies of matching requests onto the audit log under `body_capture.request_body` (and `body_capture.request_body_truncated` when the body exceeds the cap). Observation-only: it never rejects a request, and read errors are annotated on the trace rather than failing the request. Response bodies are not captured. | Field | Type | Default | Description | | ------------------------ | --------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `max_request_body_bytes` | integer | `16384` (16 KiB) | Per-request capture cap. Bodies larger than this are truncated to the prefix and `request_body_truncated` is set to `true`. Independent of `proxy.max_request_body_bytes`. | | `rules` | object\[] | `[]` | Restrict capture to specific destinations. Uses the same format as [`allowlist.rules[]`](#allowlistrules). | ```yaml - name: body_capture config: max_request_body_bytes: 16384 rules: - host: "api.anthropic.com" methods: ["POST"] paths: ["/v1/messages"] ``` When `secrets` runs with `match_body: true`, place `body_capture` before `secrets` so the audit log records the workload's proxy tokens rather than the real credentials swapped in. #### `annotate` Captures HTTP request header values into audit log annotations based on host, method, and path rules. This is useful for enriching audit logs with request-specific context like request IDs or authorization tokens. This transform never rejects requests. | Field | Type | Default | Description | | ------------- | --------- | ------- | ------------------------------------- | | `annotations` | object\[] | `[]` | List of annotation groups. See below. | ##### `annotate.annotations[]` | Field | Type | Description | | --------- | --------- | --------------------------------------------------------------------------------------------------------------------- | | `rules` | object\[] | Rules to match requests against. Uses the same format as [`allowlist.rules[]`](#allowlistrules). | | `headers` | string\[] | Header names to capture from matching requests. Values are written as `header:` entries in the transform trace. | ```yaml - name: annotate config: annotations: - rules: - host: "api.openai.com" methods: ["POST"] paths: ["/v1/*"] headers: ["x-request-id", "authorization"] ``` Example audit log annotation output: ```json { "header:X-Request-Id": "req-abc123", "header:Authorization": "Bearer sk-ant-..." } ``` #### `grpc` Delegates request and response processing to an external gRPC server implementing the `TransformService` API. You can define multiple `grpc` transforms to pipeline through several servers. | Field | Type | Default | Description | | -------------------- | --------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | string | **required** | Identifier for this transform, used in logs and error messages. | | `target` | string | **required** | gRPC server address (e.g., `localhost:9500`). | | `send_request_body` | boolean | `false` | When `true`, forward the full request body to the gRPC server. When `false`, only headers are sent. | | `send_response_body` | boolean | `false` | When `true`, forward the full response body to the gRPC server. When `false`, only headers are sent. | | `rules` | object\[] | `[]` | Restrict which requests are forwarded to this server. Uses the same rule format as [`allowlist.rules[]`](#allowlistrules). If empty, all requests are forwarded. | | `tls` | object | | TLS configuration for the gRPC connection. See below. | ##### `grpc.tls` | Field | Type | Default | Description | | --------- | ------- | -------------- | -------------------------------------------------------------------------------- | | `enabled` | boolean | `false` | Enable TLS for the gRPC connection. When `false`, the connection uses plaintext. | | `ca_cert` | string | system default | Path to a custom CA certificate for server verification. | | `cert` | string | | Path to a client certificate for mTLS. Must be set together with `key`. | | `key` | string | | Path to the client private key for mTLS. Must be set together with `cert`. | #### `judge` Calls a large language model to produce an allow or deny decision for outbound requests that match the instance's rules. Each `judge` entry is an independent instance with its own natural-language policy, LLM backend, semaphore, and circuit breaker. See the [LLM Judge reference](/policies/llm-judge) for a full overview, including writing policies, pipeline ordering, and envelope limits. The judge can only reject. It never approves a request that the static allowlist would have denied. | Field | Type | Default | Description | | ----------------- | --------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | string | **required** | Identifier for this instance, used in audit logs. Must be unique across judge instances. | | `prompt` | string | **required** | Natural-language policy sent to the LLM. Describes what is allowed and what is not. | | `rules` | object\[] | **required** | Destinations this judge applies to. Uses the same format as [`allowlist.rules[]`](#allowlistrules). At least one rule is required. | | `provider` | object | **required** | LLM backend configuration. See [providers](#judge-providers) below. | | `fallback` | string | `"deny"` | Behavior when the LLM call fails, times out, returns malformed output, or the circuit breaker is open. One of `deny` (reject with HTTP 403) or `skip` (continue to the next transform). | | `timeout` | duration | `"8s"` | Maximum time for a single LLM call. | | `max_concurrent` | integer | `100` | Maximum concurrent in-flight LLM calls for this instance. Additional requests wait for a slot. | | `circuit_breaker` | object | | Consecutive-failure breaker settings. See below. | ##### `judge.circuit_breaker` | Field | Type | Default | Description | | ---------------------- | -------- | ------- | --------------------------------------------------------------------- | | `consecutive_failures` | integer | `5` | Number of consecutive failures that trips the breaker open. | | `cooldown` | duration | `"10s"` | How long the breaker stays open before admitting a single probe call. | ##### Judge Providers The `provider` block selects and configures the LLM backend. The `type` field discriminates the shape of the block. Two providers are supported: `anthropic` and `openai`. ##### `anthropic` Calls the Anthropic Messages API. | Field | Type | Default | Description | | ------------- | ------- | --------------------------- | ------------------------------------------------------- | | `type` | string | | Must be `anthropic`. | | `model` | string | **required** | Anthropic model ID (e.g., `claude-haiku-4-5-20251001`). | | `api_key_env` | string | **required** | Environment variable holding the Anthropic API key. | | `base_url` | string | `https://api.anthropic.com` | Override the API base URL. | | `max_tokens` | integer | `256` | Maximum tokens in the model response. | ```yaml provider: type: "anthropic" model: "claude-haiku-4-5-20251001" api_key_env: "ANTHROPIC_API_KEY" max_tokens: 256 ``` ##### `openai` Calls the OpenAI Chat Completions API. | Field | Type | Default | Description | | ------------- | ------- | ------------------------ | -------------------------------------------------------------------------- | | `type` | string | | Must be `openai`. | | `model` | string | **required** | OpenAI model ID (e.g., `gpt-5.4-nano`). | | `api_key_env` | string | **required** | Environment variable holding the OpenAI API key. | | `base_url` | string | `https://api.openai.com` | Override the API base URL. Useful for Azure OpenAI or gateway deployments. | | `max_tokens` | integer | `256` | Maximum tokens in the model response. Sent as `max_completion_tokens`. | ```yaml provider: type: "openai" model: "gpt-5.4-nano" api_key_env: "OPENAI_API_KEY" max_tokens: 256 ``` ##### Full Judge Example ```yaml - name: judge config: name: "github-write-guard" fallback: "deny" timeout: "8s" max_concurrent: 100 circuit_breaker: consecutive_failures: 5 cooldown: "10s" rules: - host: "api.github.com" methods: ["POST", "PATCH", "DELETE", "PUT"] provider: type: "anthropic" model: "claude-haiku-4-5-20251001" api_key_env: "ANTHROPIC_API_KEY" max_tokens: 256 prompt: | This agent performs code review on the repository under review. Allow writes to the comments and reviews endpoints of the specific repository under review. Deny writes to user settings, organization management, billing, or any repository the agent is not reviewing. ``` Place the `judge` transform before the `secrets` transform so the LLM provider sees proxy tokens rather than real credentials. See [pipeline ordering](/policies/llm-judge#pipeline-ordering) for details. #### `header_allowlist` Strips any request header not present in a configured allowlist before forwarding the request upstream. The allowlist uses a default-deny model: every header must match either a literal name or a regex pattern to pass through. Stripped header names are recorded in the trace annotation `stripped_headers`. Place this transform after any transforms that inject headers (such as `secrets`) so injected headers survive the allowlist. | Field | Type | Default | Description | | --------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `headers` | string\[] | `[]` | Allowed header names. Each entry is either a literal name (case-insensitive) or a `/regex/` pattern compiled at config time as a case-insensitive regular expression matched against canonical header names. | | `rules` | object\[] | `[]` | Restrict the allowlist to specific destinations. Uses the same format as [`allowlist.rules[]`](#allowlistrules). When omitted, the allowlist applies to all requests. | ```yaml - name: header_allowlist config: headers: - "Authorization" - "Content-Type" - "User-Agent" - "Accept" - "/^X-Trace-.*$/" rules: - host: "api.openai.com" ``` ### `mcp` Top-level block that enforces a default-deny tool allowlist on Streamable HTTP MCP servers. The interceptor runs after the transform pipeline, so `allowlist` still gates which hosts can be reached and `secrets` has already swapped proxy tokens before the interceptor evaluates the body. See the [MCP Interception reference](/policies/mcp-interception) for a full overview, including matchers, audit log behavior, and current limitations. | Field | Type | Default | Description | | --------- | --------- | ------- | ------------------------------------------------------------- | | `error` | object | | JSON-RPC error envelope returned for denied calls. See below. | | `servers` | object\[] | `[]` | List of MCP server policies. See below. | #### `mcp.error` | Field | Type | Default | Description | | --------- | ------- | -------------------------------- | ------------------------------------------------------------------------ | | `code` | integer | `-32001` | JSON-RPC error code returned to the agent when a `tools/call` is denied. | | `message` | string | `"blocked by iron-proxy policy"` | Error message returned alongside `code`. | #### `mcp.servers[]` | Field | Type | Default | Description | | ------- | --------- | ------------ | ---------------------------------------------------------------------------------------------------- | | `name` | string | **required** | Identifier for this server policy, used in audit logs. | | `rules` | object\[] | **required** | Destinations this policy applies to. Uses the same format as [`allowlist.rules[]`](#allowlistrules). | | `tools` | object\[] | `[]` | Tools allowed on this server. Anything not listed is denied (default-deny). | #### `mcp.servers[].tools[]` | Field | Type | Default | Description | | ------ | --------- | ------------ | ----------------------------------------------------------------------------------------------------------- | | `name` | string | **required** | Tool name to allow. Matches the `name` field in the MCP `tools/call` request and the `tools/list` response. | | `when` | object\[] | `[]` | Argument matchers. All matchers must pass for the call to be allowed. See below. | #### `mcp.servers[].tools[].when[]` | Field | Type | Default | Description | | --------- | ------ | ------------ | -------------------------------------------------------------------------------------------------- | | `path` | string | **required** | Dotted path within `params.arguments` to evaluate (e.g., `"owner"`, `"repo"`). | | `equals` | any | | Exact match. Mutually exclusive with `in` and `matches`. | | `in` | array | | Match any of the listed values. Mutually exclusive with `equals` and `matches`. | | `matches` | string | | Regular expression match against the stringified value. Mutually exclusive with `equals` and `in`. | ```yaml mcp: error: code: -32001 message: "blocked by iron-proxy policy" servers: - name: github rules: - host: "mcp.github.com" paths: ["/mcp", "/mcp/*"] tools: - name: "search_repositories" - name: "create_issue" when: - path: "owner" equals: "ironsh" - path: "repo" in: ["iron-proxy", "tunis-v2"] ``` ### `management` Configures an opt-in, bearer-authenticated HTTP management API. When set, iron-proxy listens on the configured address and exposes endpoints that operate on the running process. The API is disabled when the `management` block is omitted. The first available endpoint is `POST /v1/reload`, which re-reads the YAML config from disk and atomically swaps in a freshly built transform pipeline with no restart and no dropped connections. Parse or build errors return HTTP 422 and leave the running pipeline untouched. | Field | Type | Default | Description | | ------------- | ------ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `listen` | string | **required** | Address and port for the management API. Bind to a loopback address (e.g., `127.0.0.1:9092`) unless the API is exposed via a separate network. | | `api_key_env` | string | `IRON_MANAGEMENT_API_KEY` | Environment variable holding the bearer token clients must present in the `Authorization: Bearer ...` header. | ```yaml management: listen: "127.0.0.1:9092" api_key_env: "IRON_MANAGEMENT_API_KEY" ``` Reload a running proxy: ```sh curl -X POST http://127.0.0.1:9092/v1/reload \ -H "Authorization: Bearer $IRON_MANAGEMENT_API_KEY" ``` ### `metrics` Configures the OpenTelemetry/Prometheus metrics endpoint. | Field | Type | Default | Description | | -------- | ------ | --------- | ------------------------------------------ | | `listen` | string | `":9090"` | Address and port for the metrics endpoint. | ### `log` Configures logging output. | Field | Type | Default | Description | | ------- | ------ | -------- | ---------------------------------------------------------- | | `level` | string | `"info"` | Log verbosity. One of `debug`, `info`, `warn`, or `error`. | ### OpenTelemetry Environment Variables OTEL log export is configured through environment variables, not the YAML config file. Set `OTEL_EXPORTER_OTLP_ENDPOINT` to enable it. See the [OTEL export guide](/guides/otel-export) for usage examples. | Variable | Type | Default | Description | | ----------------------------- | ------ | --------------- | ------------------------------------------------------------------------------------------------------------------ | | `OTEL_EXPORTER_OTLP_ENDPOINT` | string | (disabled) | OTLP collector URL (e.g., `https://otel-collector.example.com:4318`). Export is disabled when unset. | | `OTEL_EXPORTER_OTLP_PROTOCOL` | string | `http/protobuf` | Transport protocol: `http/protobuf` or `grpc`. | | `OTEL_EXPORTER_OTLP_HEADERS` | string | (none) | Comma-separated `key=value` pairs sent as headers on every export request. Typically used for authentication. | | `OTEL_SERVICE_NAME` | string | `iron-proxy` | Service name attached to all log records. | | `OTEL_RESOURCE_ATTRIBUTES` | string | (none) | Comma-separated `key=value` resource attributes added to all log records (e.g., `deployment.environment=staging`). | ## Header Allowlist The `header_allowlist` transform strips any request header not in a configured allowlist before forwarding upstream. Workloads send a lot of headers you may not want upstream to see: cookies, internal trace IDs, debug headers, telemetry. `header_allowlist` enforces a minimum surface area at egress regardless of what the workload's HTTP client is doing. The default is deny: every header must match a literal name or a regex pattern to pass through. Stripped header names appear in the audit log under `stripped_headers`. The header allowlist is configured as a [transform](/reference/configuration#header_allowlist) in your iron-proxy YAML. ### What You Can Match Entries are either literal header names (case-insensitive) or `/regex/` patterns. Regex patterns are compiled at config load time and matched against canonical header names case-insensitively. ```yaml - name: header_allowlist config: headers: - "Authorization" - "Content-Type" - "User-Agent" - "Accept" - "/^X-Trace-.*$/" rules: - host: "api.openai.com" ``` The `rules` field scopes the allowlist to specific destinations using the same shape as the [host allowlist's rules](/policies/host-allowlist). Omit `rules` to apply the allowlist to every request. ### Pipeline Position Place `header_allowlist` after any transform that injects headers: `secrets`, the OAuth/AWS/HMAC/GCP signing transforms, and any other transform that writes a header. Otherwise the injected headers are stripped before egress. ```yaml transforms: - name: allowlist # decide which hosts can be reached - name: secrets # inject real credentials - name: header_allowlist # strip anything else ``` See the [configuration reference](/reference/configuration#header_allowlist) for the full schema. ## Host Allowlist The `allowlist` transform decides which destinations the workload can reach. Requests to hosts not in the allowlist return 403. This is the default-deny boundary; everything else iron-proxy does runs on top of it. The allowlist is configured as a [transform](/reference/configuration#allowlist) in your iron-proxy YAML. ### What You Can Match The allowlist accepts three kinds of entries: flat domain globs, flat CIDR ranges, and structured rules with method and path filters. ```yaml transforms: - name: allowlist config: domains: - "registry.npmjs.org" - "*.anthropic.com" cidrs: - "10.0.0.0/8" rules: - host: "api.github.com" methods: ["GET"] paths: ["/repos/*"] ``` Flat `domains` and `cidrs` allow all methods and paths on the matched destination. `rules` exist when you need to restrict by method or path on a per-destination basis. Both can appear in the same allowlist. A bare `"*"` matches any host. Use it as a catch-all when you want to log traffic without restricting it. ### Warn Mode `warn: true` logs allowlist violations without blocking the request. The audit log records the host that would have been blocked and the policy that matched. ```yaml - name: allowlist config: warn: true domains: - "registry.npmjs.org" ``` Useful when you're rolling iron-proxy in front of an existing workload and don't yet know the full list of hosts it talks to. Run in `warn` for a few days, read the log, tighten the list, then flip `warn` off. ### Pipeline Position Place `allowlist` first in your `transforms:` list. Other transforms (credential injection, signing, MCP interception) only need to run on requests the allowlist will permit; putting `allowlist` first avoids work on requests that will be rejected. See the [configuration reference](/reference/configuration#allowlist) for the full schema. ## LLM Judge The `judge` transform calls a large language model to produce an allow or deny decision for outbound HTTP requests. Each judge instance carries its own natural-language policy, LLM backend, and URL rules. You can deploy zero, one, or many judges with different prompts scoped to different destinations. The judge is configured as a [transform](/reference/configuration#judge) in your iron-proxy YAML config. ### How It Works Each judge instance runs in the transform pipeline with its own state: 1. **Rule matching**: when a request matches the instance's `rules`, the judge runs. Non-matching requests pass through untouched, with no LLM call and no annotations. 2. **Envelope construction**: the request is serialized into a JSON envelope (method, URL, headers, body) with per-field size caps and priority ordering for security-relevant headers. 3. **LLM call**: the envelope is sent to the configured provider along with a system prompt that embeds the operator's policy. The system prompt instructs the model to return a bare JSON decision. 4. **Decision**: the model returns `{"decision":"ALLOW", ...}` or `{"decision":"DENY", ...}`. A deny short-circuits the pipeline with HTTP 403. An allow continues to the next transform. 5. **Fallback**: on LLM error, timeout, malformed output, or open circuit breaker, the configured `fallback` applies (see [Failure Handling](#failure-handling)). ### Invariants The judge is an additional rejection layer. It cannot override iron-proxy's other controls: * **The judge can only reject**. It never approves a request that the static allowlist would have denied. The allowlist's deny always wins. * **Non-matching requests are ignored**. If a request does not match the instance's `rules`, no LLM call is made and no audit annotations are written. * **Default-deny semantics are preserved**. If an instance skips on failure, the rest of the pipeline still applies, and unmatched requests are still blocked by the allowlist. ### Pipeline Ordering The relative position of the `judge` and `secrets` transforms in your configuration determines what the LLM provider sees. :::info **Recommended: place the judge before the secrets transform.** The LLM provider sees proxy tokens, never the real credentials the workload has access to. This is the safer default and is the only placement compatible with typical threat models. ::: ```yaml transforms: - name: allowlist config: {...} - name: judge # runs first: LLM sees proxy tokens only config: {...} - name: secrets # real credentials injected here config: {...} ``` **Alternative: place the judge after secrets.** The judge evaluates the exact wire form that will egress, including any injected credentials. Only choose this if your threat model accepts sending real secrets to the LLM provider. ### Configuration A judge instance is a single entry under `transforms:`. See the [configuration reference](/reference/configuration#judge) for the full schema. ```yaml transforms: - name: judge config: name: "github-write-guard" fallback: "deny" timeout: "8s" max_concurrent: 100 circuit_breaker: consecutive_failures: 5 cooldown: "10s" rules: - host: "api.github.com" methods: ["POST", "PATCH", "DELETE", "PUT"] provider: type: "anthropic" model: "claude-haiku-4-5-20251001" api_key_env: "ANTHROPIC_API_KEY" max_tokens: 256 prompt: | This agent performs code review on the repository under review. Allow writes to the comments and reviews endpoints of the specific repository under review. Deny writes to user settings, organization management, billing, or any repository the agent is not reviewing. ``` #### Writing a Good Policy The `prompt` field is a natural-language description of what is allowed. A few guidelines: * **Keep it short and specific.** A focused policy produces more consistent decisions than a long one. Aim for a paragraph or two. * **Scope the judge's rules to the smallest set of destinations the policy covers.** A judge whose policy only mentions GitHub should have rules that only match GitHub. Everything else should be handled by the static allowlist or other judges. * **State both what is allowed and what is not.** Positive and negative examples help the model resolve ambiguity. * **Default to deny in the policy itself.** If the policy leaves a case undefined, the system prompt instructs the model to prefer DENY, but being explicit reduces guesswork. The operator policy is JSON-escaped before being embedded in the system prompt, so quotes, braces, and newlines in your policy are safe. Prompt-injection-shaped text inside the policy is treated as data, not instructions. ### Providers The judge supports two LLM backends. Both use Anthropic's or OpenAI's public APIs and expect an API key supplied via an environment variable on the iron-proxy process. #### Anthropic (`type: anthropic`) Calls the Messages API at `https://api.anthropic.com/v1/messages`. ```yaml provider: type: "anthropic" model: "claude-haiku-4-5-20251001" api_key_env: "ANTHROPIC_API_KEY" max_tokens: 256 ``` | Field | Type | Default | Description | | ------------- | ------- | --------------------------- | --------------------------------------------------------------------- | | `type` | string | **required** | Must be `anthropic`. | | `model` | string | **required** | Anthropic model ID (e.g., `claude-haiku-4-5-20251001`). | | `api_key_env` | string | **required** | Name of the environment variable holding the Anthropic API key. | | `base_url` | string | `https://api.anthropic.com` | Override the API base URL. Useful for testing or gateway deployments. | | `max_tokens` | integer | `256` | Maximum tokens in the model response. | #### OpenAI (`type: openai`) Calls the Chat Completions API at `https://api.openai.com/v1/chat/completions`. ```yaml provider: type: "openai" model: "gpt-5.4-nano" api_key_env: "OPENAI_API_KEY" max_tokens: 256 ``` | Field | Type | Default | Description | | ------------- | ------- | ------------------------ | -------------------------------------------------------------------------- | | `type` | string | **required** | Must be `openai`. | | `model` | string | **required** | OpenAI model ID (e.g., `gpt-5.4-nano`). | | `api_key_env` | string | **required** | Name of the environment variable holding the OpenAI API key. | | `base_url` | string | `https://api.openai.com` | Override the API base URL. Useful for Azure OpenAI or gateway deployments. | | `max_tokens` | integer | `256` | Maximum tokens in the model response. Sent as `max_completion_tokens`. | ### Failure Handling Every judge instance has its own timeout, semaphore, and circuit breaker. A failing judge never blocks an unrelated judge. #### Timeout The `timeout` field bounds a single LLM call. On timeout, the call is canceled and the configured fallback applies. #### Concurrency The `max_concurrent` field caps the number of in-flight LLM calls for this instance. Additional requests wait for a slot. This protects the proxy from runaway concurrency against a slow LLM endpoint. #### Circuit Breaker Each instance has an independent consecutive-failure breaker: * After `consecutive_failures` errors in a row, the breaker opens and short-circuits subsequent calls for `cooldown`. During this window, the fallback applies without an LLM call. * After the cooldown elapses, the breaker enters a half-open state and admits a single probe call. Success closes the breaker; failure reopens it with a fresh cooldown. * A single successful call resets the failure counter, so transient errors do not slowly accumulate. #### Fallback The `fallback` field determines what happens when the LLM call fails or returns something unusable. Two modes are supported: * **`deny`** (default, recommended for production): the request is rejected with HTTP 403. Safe under any failure mode. * **`skip`**: the judge yields to the rest of the pipeline. Since iron-proxy is default-deny, unmatched requests are still blocked, but requests that the static allowlist would accept will no longer be gated by this judge. A fallback fires on: * LLM request error (network, 4xx, 5xx). * LLM call timeout. * Circuit breaker open. * Malformed model output (not valid JSON, or `decision` not `ALLOW`/`DENY`). * Errors reading the request body or building the envelope. The `allow` fallback does not exist: the judge cannot upgrade a failure into a successful allow. Pick `deny` when the judge is a hard gate and `skip` only when the judge is advisory and the underlying allowlist already enforces safety. ### Envelope Limits Before the request is sent to the LLM, it is serialized into a JSON envelope with per-field size caps. Content beyond the cap is dropped and a warning is added so the model can see that truncation happened. The model is instructed to prefer DENY when truncation warnings could plausibly matter. | Field | Cap | Notes | | --------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Body | 16 KiB | Non-UTF-8 bodies are omitted entirely with a warning. Multipart bodies beyond the cap emit a placeholder summary. | | URL | 2 KiB | Truncated with a warning showing the original length. | | Headers (total) | 4 KiB | Security-relevant headers (`Host`, `Origin`, `Referer`, `X-Forwarded-For`, `X-Forwarded-Host`, `Content-Type`, `Content-Length`, `Content-Encoding`, `Transfer-Encoding`, `Authorization`, `Cookie`) are emitted first, then the rest in alphabetical order. | | Header value | 512 bytes | Values longer than this are truncated with a marker noting the original length. | The priority-header ordering defeats header-inflation attacks: even when an attacker packs the envelope with junk headers, the security-relevant ones are always visible to the model. ### Audit Output Every matched request adds structured fields under the transform trace: | Field | Description | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `judge.instance` | The instance `name`. Use this to disambiguate between multiple judges. | | `judge.model` | The model ID used for the decision. | | `judge.decision` | `ALLOW`, `DENY`, `FALLBACK_ALLOW`, or `FALLBACK_DENY`. | | `judge.reason` | Short justification. For successful calls, comes from the model. For fallbacks, describes the failure. Capped at 512 characters. | | `judge.duration_ms` | Total time spent in the judge, in milliseconds, including the LLM call. | | `judge.input_tokens` | Tokens the provider billed for the prompt. Present only when the LLM call succeeded. | | `judge.output_tokens` | Tokens the provider billed for the response. Present only when the LLM call succeeded. | | `judge.fallback_applied` | Present only when a fallback fired. One of `deny` or `skip`. | | `judge.circuit_breaker_tripped` | Present and set to `true` only when the breaker was open. | | `judge.raw_output` | Present only when decision parsing failed. Contains the first 2 KiB of the raw model output for debugging. | ### Multiple Instances A single configuration can define many judge transforms. Each entry is an independent instance: its own `name`, `prompt`, `rules`, provider, semaphore, and circuit breaker. A failing or slow judge does not affect the others. ```yaml transforms: - name: judge config: name: "github-write-guard" rules: - host: "api.github.com" methods: ["POST", "PATCH", "DELETE", "PUT"] provider: type: "anthropic" model: "claude-haiku-4-5-20251001" api_key_env: "ANTHROPIC_API_KEY" prompt: | Allow writes to the repository under review. Deny writes to user settings, billing, or any other repository. - name: judge config: name: "slack-dm-guard" rules: - host: "slack.com" paths: ["/api/chat.postMessage"] provider: type: "openai" model: "gpt-5.4-nano" api_key_env: "OPENAI_API_KEY" prompt: | Allow posts to the #release-bot channel. Deny posts to any other channel or direct messages. ``` A single request may match at most one judge per instance, and each matched instance runs independently. If two instances both match, both make an LLM call and both must allow. ## MCP Interception iron-proxy can enforce a default-deny tool allowlist on Model Context Protocol (MCP) servers. Denied `tools/call` invocations are rejected with a JSON-RPC error envelope before they reach the upstream server, and `tools/list` responses are filtered so denied tools never appear in the agent's tool catalog. MCP interception is configured under the top-level [`mcp` block](/reference/configuration#mcp) in your iron-proxy YAML config. ### How It Works When a request matches an `mcp.servers[]` entry, iron-proxy parses the JSON-RPC body and applies the following rules: 1. **`tools/call` (request)**: the tool name and arguments are matched against the configured allowlist. Calls that match an allowed tool (and pass any `when` matchers) are forwarded upstream. Calls that do not match are rejected with the configured JSON-RPC error envelope, without contacting the upstream server. 2. **`tools/list` (response)**: each tool in the response is matched against the allowlist. Tools that are not allowed are stripped from the response before it reaches the agent. Filtering runs per-event over both `application/json` and `text/event-stream` responses. 3. **Other JSON-RPC methods**: forwarded unchanged. Resources and prompts (`resources/list`, `resources/read`, etc.) are not policy-filtered in this version. The interceptor runs after the transform pipeline. This means: * The `allowlist` transform still gates which hosts can be reached. MCP rules apply only to traffic that the allowlist already permits. * The `secrets` transform has already swapped proxy tokens for real values by the time the interceptor evaluates the JSON-RPC body. Argument matchers see real argument values, not proxy tokens. ### Configuration The `mcp` block has two parts: an `error` template that shapes the JSON-RPC error envelope returned for denied calls, and a `servers` list that declares per-server policies. ```yaml mcp: error: code: -32001 message: "blocked by iron-proxy policy" servers: - name: github rules: - host: "mcp.github.com" paths: ["/mcp", "/mcp/*"] tools: - name: "search_repositories" - name: "create_issue" when: - path: "owner" equals: "ironsh" - path: "repo" in: ["iron-proxy", "tunis-v2"] ``` In this example, requests to `mcp.github.com/mcp` and `mcp.github.com/mcp/*` go through the `github` policy. The agent can call `search_repositories` with any arguments and `create_issue` only when `owner` is `ironsh` and `repo` is one of the listed repositories. Every other tool name is denied. `tools/list` responses are filtered to expose only `search_repositories` and `create_issue`. See the [configuration reference](/reference/configuration#mcp) for the full schema. ### Argument Matchers Each entry under `tools[]` may carry a `when` list that constrains which argument values are allowed. All matchers must pass for the call to be allowed; if any fails, the call is denied. The `path` field selects a value from `params.arguments` using dotted notation. | Matcher | Meaning | | --------- | ----------------------------------------------------------------------------- | | `equals` | The value at `path` must equal the supplied value. | | `in` | The value at `path` must be one of the supplied values. | | `matches` | The value at `path` (stringified) must match the supplied regular expression. | ```yaml tools: - name: "create_issue" when: - path: "owner" equals: "ironsh" - path: "repo" in: ["iron-proxy", "tunis-v2"] - path: "title" matches: "^\\[bot\\]" ``` A tool entry without a `when` block allows the tool to be called with any arguments. ### Denied Calls When a `tools/call` is denied, iron-proxy returns a JSON-RPC error response using the configured `mcp.error` template. The response preserves the request's `id` so the agent's MCP client correlates the error with the originating call. The upstream server is never contacted. ```json { "jsonrpc": "2.0", "id": 42, "error": { "code": -32001, "message": "blocked by iron-proxy policy" } } ``` Choose `code` and `message` values that your agent recognizes. The defaults are suitable for most agents, since `-32001` falls in the JSON-RPC server-defined error range. ### Tool List Filtering `tools/list` responses are filtered per-event so denied tools never appear in the agent's catalog. Filtering applies to both `application/json` responses and `text/event-stream` streamed responses, which keeps tool selection consistent with the enforced policy. ### Audit Log The audit log gains a top-level `mcp` section with one structured record per JSON-RPC message observed. Each record captures the message type, the tool name (when present), and the resulting policy decision. Use this to verify that denied calls match your expectations and to investigate agent behavior over time. ### Pipeline Ordering The interceptor evaluates the request body after the transform pipeline finishes. Place transforms accordingly: * **`allowlist`**: must permit the MCP server's host before MCP rules can apply. A request blocked by the allowlist never reaches the interceptor. * **`secrets`**: any proxy tokens are replaced before MCP evaluates `params.arguments`. Argument matchers see real values, so write `equals` or `in` matchers against real argument values rather than proxy tokens. * **`judge`**: place an LLM judge before `secrets` if you want it to evaluate proxy tokens, as documented in the [LLM Judge reference](/policies/llm-judge#pipeline-ordering). The MCP interceptor runs after both. ### Limitations The current MCP interceptor (v1) has the following limitations: * Only the Streamable HTTP transport is supported. Legacy HTTP plus SSE transport is not. * A JSON-RPC batch is rejected as a whole if any entry in the batch is denied. Per-entry partial denial is not supported. * Resources and prompts (`resources/list`, `resources/read`, and related methods) are not policy-filtered. Only `tools/call` and `tools/list` are evaluated. These limitations may change in future releases. Check the [release notes](https://github.com/ironsh/iron-proxy/releases) for updates. ## Concept: Transforms A transform is a single processing step that runs on every outbound request. Every policy in iron-proxy is configured as a transform: the host allowlist, credential injection, signing, the LLM judge, header stripping. They all share the same shape (a `name` and a `config` block), live in the same ordered list, and run on every request. ```yaml transforms: - name: allowlist config: ... - name: secrets config: ... - name: header_allowlist config: ... ``` For each request, iron-proxy runs the transforms top-to-bottom. Each one inspects the request, optionally modifies it, and either passes control to the next transform or rejects the request. The request is forwarded upstream only if every transform passes. ### Rejection Short-Circuits If a transform rejects, the rest don't run. The workload gets a 4xx response (the status code depends on the transform; each policy page calls out its own). The audit log records the rejection with the name of the transform that decided and the reason. ### Scoping With `rules` Most transforms accept a `rules` field that limits them to specific destinations: ```yaml - name: secrets config: secrets: - source: { type: env, var: GITHUB_TOKEN } replace: ... rules: - host: "api.github.com" methods: ["GET", "POST"] paths: ["/repos/*"] ``` `rules` uses the same shape as the [host allowlist's rules](/policies/host-allowlist). Each rule matches a host glob or CIDR with optional method and path filters. A transform with `rules` only acts on requests that match; everything else passes through untouched. `allowlist` is the exception: its `rules` are the allowlist itself, not a scope modifier. ### Ordering Order matters in a few specific cases. Each policy page calls out its preferred position; the general rules: * **`allowlist` first.** Cheap rejections should happen before any other transform does work on a request that will be denied. * **`judge` before `secrets`.** The LLM provider sees proxy tokens, not real credentials. See [pipeline ordering](/policies/llm-judge#pipeline-ordering) for the tradeoffs. * **`header_allowlist` after credential-injecting transforms.** Otherwise the injected headers (`Authorization`, signatures, etc.) get stripped before egress. When two transforms of the same type target the same destination, the first one in config order wins. ### Audit Annotations Every transform writes an entry to the per-request audit trace. The entry records the transform's name, what it did (`continue`, `reject`, or annotations like `swapped` and `stripped_headers`), and any per-transform context. The audit log lets you answer questions like "did the OAuth refresh happen?" or "which header got stripped?" without instrumenting the workload. ### Available Transforms * [Host Allowlist](/policies/host-allowlist): default-deny on destination hosts, methods, and paths. * [Credential Proxying](/credential-proxying/overview): substitute, mint, or sign credentials at the boundary. Includes static secrets, OAuth2, AWS SigV4, HMAC, and GCP service accounts. * [LLM Judge](/policies/llm-judge): semantic policy via an LLM call. * [Header Allowlist](/policies/header-allowlist): default-deny on request headers. * [Body Capture](/reference/configuration#body_capture) and [Header Capture](/reference/configuration#header_capture): copy request bodies or header values into the audit log without modifying the request. * [gRPC](/reference/configuration#grpc): delegate request and response processing to an external server. [MCP Interception](/policies/mcp-interception) is configured separately under the top-level `mcp` block, not as a transform, and runs after the transform pipeline. See the [configuration reference](/reference/configuration#transforms) for the full schema of each. ## Managing CA Certificates iron-proxy intercepts TLS traffic by acting as a man-in-the-middle: it terminates the client's TLS connection, inspects or transforms the request, then opens a new TLS connection to the upstream server. To do this, it needs a CA certificate and private key to mint leaf certificates on the fly for each upstream host. This page applies to the default TLS mode (`mitm`). If you only need host-level egress control and cannot distribute a CA certificate to workloads, iron-proxy also supports an [SNI-only mode](/reference/configuration#tls-modes) that passes TLS through without termination and needs no CA. ### Generating a CA Certificate Generate the CA in two steps: first create a private key, then issue a self-signed certificate with the proper X.509 extensions. ```sh mkdir -p certs openssl genrsa -out certs/ca.key 4096 openssl req -x509 -new -nodes \ -key certs/ca.key \ -sha256 -days 90 \ -subj "/CN=iron-proxy CA" \ -addext "basicConstraints=critical,CA:TRUE" \ -addext "keyUsage=critical,keyCertSign" \ -out certs/ca.crt ``` #### Parameters | Parameter | Value | Purpose | | ------------- | ------------------- | ----------------------------------------------------------------------------------------------------------- | | `-x509` | | Generate a self-signed certificate instead of a CSR. | | `-new -nodes` | | Create a new certificate with an unencrypted private key. | | `-key` | `certs/ca.key` | Path to the private key generated by `openssl genrsa`. | | `-sha256` | | Use SHA-256 for the certificate signature hash. | | `-out` | `certs/ca.crt` | Output path for the CA certificate (PEM format). | | `-days` | `90` | Certificate validity period in days. See [Expiry Recommendations](#expiry-recommendations). | | `-subj` | `/CN=iron-proxy CA` | Certificate subject. The CN value is arbitrary: pick something descriptive so the cert is easy to identify. | #### X.509 Extensions The `-addext` flags set X.509v3 extensions that mark this certificate as a CA. Both are required: | Extension | Value | Purpose | | ------------------ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `basicConstraints` | `critical,CA:TRUE` | Identifies the certificate as a certificate authority. The `critical` flag means clients must reject the certificate if they do not understand this extension. | | `keyUsage` | `critical,keyCertSign` | Restricts the key to signing other certificates. This is the minimum permission iron-proxy needs to mint leaf certificates for TLS interception. | Without these extensions, some TLS implementations will reject leaf certificates signed by the CA because the issuer is not properly marked as a certificate authority. ### Expiry Recommendations The CA certificate's lifetime should match how the proxy is deployed: | Deployment | Recommended Expiry | Rationale | | ---------------------------- | ---------------------------- | ---------------------------------------------------------------------------------- | | CI/CD (e.g., GitHub Actions) | Ephemeral, generated per-run | The CA only needs to live for the duration of the job. No trust management needed. | | Short-lived containers | 1 to 90 days | Keep the validity window tight. Rotate by redeploying with a new cert. | | Long-running infrastructure | 90 to 365 days | Balance operational overhead against security. Automate rotation if possible. | Shorter is better. A compromised CA key lets an attacker mint trusted certificates for any domain within your environment, so limit the blast radius by keeping expiry short and rotating regularly. ### Configuring iron-proxy Point iron-proxy at the CA certificate and key in your config file: ```yaml tls: ca_cert: "/certs/ca.crt" ca_key: "/certs/ca.key" ``` Both fields are required and must be PEM-encoded files. See the [TLS configuration reference](/reference/configuration#tls) for additional options like `cert_cache_size` and `leaf_cert_expiry_hours`. ### Trusting the CA in Workloads Clients that make HTTPS requests through iron-proxy need to trust the CA certificate. Otherwise, TLS verification will fail with a certificate authority error. How you install trust depends on the runtime. #### System Trust Store (Debian/Ubuntu/Alpine) ```sh cp ca.crt /usr/local/share/ca-certificates/iron-proxy.crt update-ca-certificates ``` Most tools that use OpenSSL or the system trust store (curl, wget, apt) will pick this up automatically. On Alpine, you may need to install the `ca-certificates` package first: ```sh apk add ca-certificates ``` #### System Trust Store (RHEL/Fedora) ```sh cp ca.crt /etc/pki/ca-trust/source/anchors/iron-proxy.crt update-ca-trust ``` #### Node.js Node.js does not use the system trust store by default. Set the `NODE_EXTRA_CA_CERTS` environment variable: ```sh export NODE_EXTRA_CA_CERTS=/certs/ca.crt ``` #### Python (requests/pip) The `requests` library and `pip` respect the `REQUESTS_CA_BUNDLE` and `SSL_CERT_FILE` variables: ```sh export SSL_CERT_FILE=/certs/ca.crt export REQUESTS_CA_BUNDLE=/certs/ca.crt ``` Alternatively, if the system trust store has been updated, `pip` and `requests` will use it automatically on most distributions. #### Java Import the CA into the JVM's trust store: ```sh keytool -importcert -trustcacerts -noprompt \ -alias iron-proxy \ -file /certs/ca.crt \ -keystore "$JAVA_HOME/lib/security/cacerts" \ -storepass changeit ``` #### Docker Compose Share the CA certificate via a volume and install trust in your container's entrypoint: ```yaml services: proxy: volumes: - certs:/certs app: volumes: - certs:/certs:ro environment: - NODE_EXTRA_CA_CERTS=/certs/ca.crt volumes: certs: ``` For containers that use the system trust store, add `update-ca-certificates` (or equivalent) to the container's entrypoint or Dockerfile. ## Configuring OTEL Export Starting in `v0.7.0`, iron-proxy can export audit logs as OpenTelemetry structured log records. This lets you send every proxied request to any OTLP-compatible backend: Axiom, ClickHouse, Logfire, Grafana Cloud, or your own collector. OTEL export runs alongside the existing JSON stderr logs. When enabled, every audit event is emitted as an OTEL log record carrying the same schema: `host`, `method`, `path`, `action`, `status_code`, `duration_ms`, and the full `request_transforms`/`response_transforms` arrays with annotations. ### Configuration OTEL export is configured entirely through environment variables. Set `OTEL_EXPORTER_OTLP_ENDPOINT` to enable it: | Variable | Description | Default | | ----------------------------- | --------------------------------------------------- | --------------- | | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector URL. Export is disabled when unset. | (disabled) | | `OTEL_EXPORTER_OTLP_PROTOCOL` | `http/protobuf` or `grpc`. | `http/protobuf` | | `OTEL_EXPORTER_OTLP_HEADERS` | Comma-separated `key=value` pairs for auth headers. | (none) | | `OTEL_SERVICE_NAME` | Service name attached to all log records. | `iron-proxy` | | `OTEL_RESOURCE_ATTRIBUTES` | Comma-separated `key=value` resource attributes. | (none) | These are standard OTEL environment variables, so they work the same way across all OpenTelemetry-instrumented services. ### Example: Docker Pass the OTEL variables as environment variables on the iron-proxy container: ```bash docker run -d --name iron-proxy \ -e OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com:4318 \ -e OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \ -e OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer " \ -e OTEL_SERVICE_NAME=iron-proxy \ -e OTEL_RESOURCE_ATTRIBUTES="deployment.environment=staging" \ -v $(pwd)/proxy.yaml:/etc/iron-proxy/proxy.yaml:ro \ -v $(pwd)/certs/ca.crt:/etc/iron-proxy/ca.crt:ro \ -v $(pwd)/certs/ca.key:/etc/iron-proxy/ca.key:ro \ ironsh/iron-proxy:latest -config /etc/iron-proxy/proxy.yaml ``` ### Example: Docker Compose Add the variables to your `environment` block: ```yaml services: proxy: image: ironsh/iron-proxy:latest command: ["-config", "/etc/iron-proxy/proxy.yaml"] environment: - OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com:4318 - OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf - OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer - OTEL_SERVICE_NAME=iron-proxy - OTEL_RESOURCE_ATTRIBUTES=deployment.environment=staging volumes: - ./proxy.yaml:/etc/iron-proxy/proxy.yaml:ro - ./certs/ca.crt:/etc/iron-proxy/ca.crt:ro - ./certs/ca.key:/etc/iron-proxy/ca.key:ro ``` ### Example: Amazon ECS If you followed the [ECS guide](/deploy/ecs), add the OTEL variables to the iron-proxy container definition: ```json { "name": "iron-proxy", "image": "docker.io/ironsh/iron-proxy:latest", "environment": [ { "name": "AWS_REGION", "value": "YOUR_REGION" }, { "name": "OTEL_EXPORTER_OTLP_ENDPOINT", "value": "https://otel-collector.example.com:4318" }, { "name": "OTEL_EXPORTER_OTLP_PROTOCOL", "value": "http/protobuf" }, { "name": "OTEL_SERVICE_NAME", "value": "iron-proxy" }, { "name": "OTEL_RESOURCE_ATTRIBUTES", "value": "deployment.environment=production" } ] } ``` For the `OTEL_EXPORTER_OTLP_HEADERS` value (which typically contains an auth token), use [AWS Secrets Manager](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/specifying-sensitive-data-secrets.html) instead of a plaintext environment variable: ```json "secrets": [ { "name": "OTEL_EXPORTER_OTLP_HEADERS", "valueFrom": "arn:aws:secretsmanager:YOUR_REGION:YOUR_ACCOUNT_ID:secret:iron-proxy/otel-headers" } ] ``` ### Verifying Once configured, iron-proxy will log a message at startup confirming the OTEL exporter is active: ``` {"level":"info","msg":"otel exporter enabled","endpoint":"https://otel-collector.example.com:4318","protocol":"http/protobuf"} ``` Check your backend for incoming log records. Each record contains the same fields as the JSON audit log, so you can query by `host`, `action`, `status_code`, or any other field. ## Tunneling with SOCKS5 and CONNECT iron-proxy can accept traffic via SOCKS5 and HTTP CONNECT tunnel requests in addition to its standard DNS-based interception. This is useful when you cannot control DNS resolution but can configure a proxy address: for example, applications that support `HTTPS_PROXY`/`ALL_PROXY` environment variables, or tools with built-in SOCKS5 support. ### How It Works The tunnel listener accepts both HTTP CONNECT and SOCKS5 protocols on a single port. iron-proxy peeks at the first byte of each connection to determine the protocol, then: 1. Resolves the target through the existing transform pipeline (allowlists and secret transforms still apply). 2. MITM's TLS connections using the configured CA, just like the standard HTTPS listener. 3. Passes plain HTTP traffic directly. 4. Rejects non-HTTP/TLS protocols. This means your allowlist, secret swapping, and audit logging all work the same way regardless of whether traffic arrives via DNS interception or the tunnel listener. :::info The tunnel listener only supports HTTP and TLS traffic. Non-HTTP protocols (e.g., raw TCP, SSH) are rejected. ::: ### Configuration Enable the tunnel listener by adding `tunnel_listen` to the `proxy` block in your iron-proxy config: ```yaml proxy: http_listen: ":80" https_listen: ":443" tunnel_listen: ":1080" ``` The listener is disabled by default. Set `tunnel_listen` to a bind address and port to enable it. Port `1080` is the conventional SOCKS5 port, but any port works. See the [configuration reference](/reference/configuration#proxy) for the full list of proxy options. ### Connecting via SOCKS5 Point your application at the tunnel listener using the `ALL_PROXY` or `HTTPS_PROXY` environment variable: ```bash export ALL_PROXY=socks5://iron-proxy-host:1080 curl https://api.example.com ``` Most HTTP clients and CLI tools respect these variables. Some tools have their own proxy settings: ```bash # Git git config --global http.proxy socks5://iron-proxy-host:1080 # wget wget --proxy=on -e use_proxy=yes -e all_proxy=socks5://iron-proxy-host:1080 https://api.example.com ``` ### Connecting via HTTP CONNECT HTTP CONNECT is the standard method used by `HTTPS_PROXY`. Most HTTP libraries and tools support it natively: ```bash export HTTPS_PROXY=http://iron-proxy-host:1080 curl https://api.example.com ``` Both `HTTPS_PROXY` and `HTTP_PROXY` work. The tunnel listener handles both protocols on the same port. ### When to Use Tunnel Mode **Use DNS interception** (the default) when you control the network stack and can point DNS at iron-proxy. This is the simplest setup and requires no per-application configuration. **Use tunnel mode** when: * You cannot control DNS resolution (e.g., managed platforms, shared infrastructure). * Applications already have proxy configuration built in. * You want to avoid TLS trust issues by letting the application negotiate TLS directly through the tunnel. * You are running tools that respect standard proxy environment variables but are hard to configure for custom DNS. You can run both at the same time. DNS interception and the tunnel listener are independent: enable one, the other, or both depending on your deployment. ### CA Certificates The tunnel listener MITM's TLS connections using the same CA as the standard HTTPS listener. Workloads connecting through the tunnel still need to trust iron-proxy's CA certificate. See [Managing CA Certificates](/guides/ca-certificates) for setup instructions. ## Deploy on Bare Metal Run iron-proxy directly on a Linux host: a physical server, a cloud VM, or any other machine where you control the network stack. This is the [embedded in a VM or sandbox](/deploy/overview#embedded-in-a-vm-or-sandbox) deployment pattern. ### How It Works iron-proxy runs as root under systemd, while workloads run as an unprivileged user. Two sets of iptables rules work together to redirect outbound HTTP and HTTPS from non-root processes to iron-proxy's local listeners on ports 8080 and 8443, while preventing workloads from connecting to arbitrary IPs or non-standard ports directly. ``` ┌──────────────────────────────────────────────────────────┐ │ Linux host │ │ │ │ ┌────────────────┐ ┌──────────────────────┐ │ │ │ workload │ │ iron-proxy (root) │ │ │ │ (uid: app) │ │ │ │ │ │ │ nat │ :8080 HTTP │ │ │ │ connects to ───┼─REDIRECT│ :8443 HTTPS MITM │ │ │ │ host:443 │────────►│ allowlist │ │ │ │ (no proxy set) │ │ secret transforms │ │ │ └────────────────┘ └──────────┬───────────┘ │ │ │ │ │ iptables filter: DROP unless uid=root │ │ │ ▼ │ │ internet │ └──────────────────────────────────────────────────────────┘ ``` ### Prerequisites * A Linux host with systemd and iptables. Debian, Ubuntu, RHEL, Amazon Linux, and similar distributions all work. * Root access on the host. * A dedicated non-root user for your workload (this guide uses `app`). If one does not already exist, create it with `sudo useradd -m app`. * Kernel support for the `xt_owner` iptables module. This is enabled by default on every mainstream distribution; you can verify with `iptables -m owner --help`. ### Setup ::::steps #### Install The Binary ```bash curl -fsSL https://iron.sh/install.sh | sh ``` To install manually, download the release tarball from the [GitHub releases page](https://github.com/ironsh/iron-proxy/releases), verify the checksums, and copy the binary to `/usr/local/bin/iron-proxy`. Confirm the install: ```bash iron-proxy version ``` #### Run iron-proxy init `iron-proxy init` generates a 90-day CA, writes a default config, installs a systemd unit, and starts the service. ```bash sudo iron-proxy init -allow "api.openai.com,api.anthropic.com" ``` It produces: | Path | Purpose | | ---------------------------------------- | --------------------------------------------- | | `/etc/iron-proxy/ca.crt` | CA certificate used to sign per-domain leaves | | `/etc/iron-proxy/ca.key` | CA private key. Never copy this off the host | | `/etc/iron-proxy/proxy.yaml` | Generated config | | `/etc/systemd/system/iron-proxy.service` | Systemd unit | | `/var/log/iron-proxy.log` | Structured JSON audit log | Check that the service is running: ```bash sudo systemctl status iron-proxy ``` Send a test request through the tunnel listener to confirm the proxy is working before applying the iptables rules: ```bash curl --cacert /etc/iron-proxy/ca.crt \ --proxy http://127.0.0.1:1080 \ https://api.openai.com/v1/models ``` An allowed host returns normally. Anything not in the allowlist returns `403`. :::warning `ca.key` signs every leaf certificate workloads on this host will trust. Keep it off backups that leave the machine and rotate if it is ever exposed. See the [CA certificate reference](/guides/ca-certificates) for rotation guidance. ::: #### Edit The Config Open `/etc/iron-proxy/proxy.yaml`. The generated file looks like this: ```yaml proxy: http_listen: ":8080" https_listen: ":8443" tunnel_listen: ":1080" dns: listen: ":8053" proxy_ip: "127.0.0.1" tls: ca_cert: "/etc/iron-proxy/ca.crt" ca_key: "/etc/iron-proxy/ca.key" transforms: - name: allowlist config: domains: - "api.openai.com" - "api.anthropic.com" log: level: "info" ``` **Expand the allowlist.** Add every domain your workloads need. Wildcards are supported. Start in warn mode while you are discovering domains: ```yaml transforms: - name: allowlist config: warn: true domains: - "api.openai.com" - "api.anthropic.com" - "*.githubusercontent.com" ``` `warn: true` logs denied requests without blocking them. Remove it to switch to enforce mode. **Inject upstream secrets.** Keep the real API key on the host and give workloads a placeholder. iron-proxy swaps the placeholder for the real value before forwarding, scoped to the domains you specify: ```yaml transforms: - name: allowlist config: domains: - "api.openai.com" - name: secrets config: secrets: - source: type: env var: OPENAI_API_KEY proxy_value: "proxy-openai-token" match_headers: ["Authorization"] require: true rules: - host: "api.openai.com" ``` Set `OPENAI_API_KEY` in the systemd unit so the proxy can see it but workloads cannot: ```bash sudo systemctl edit iron-proxy ``` ```ini [Service] Environment=OPENAI_API_KEY=sk-real-key-here ``` See the [static secrets reference](/credential-proxying/static-secrets) for the full set of source, match, and rule options. Apply config changes by restarting the service: ```bash sudo systemctl restart iron-proxy ``` #### Trust The CA System-Wide Install the CA into the system trust store so every TLS library on the host trusts iron-proxy's leaf certificates: **Debian or Ubuntu:** ```bash sudo cp /etc/iron-proxy/ca.crt /usr/local/share/ca-certificates/iron-proxy.crt sudo update-ca-certificates ``` **RHEL, CentOS, or Amazon Linux:** ```bash sudo cp /etc/iron-proxy/ca.crt /etc/pki/ca-trust/source/anchors/iron-proxy.crt sudo update-ca-trust ``` Some runtimes use their own trust bundle. Node.js uses `NODE_EXTRA_CA_CERTS`, Python `requests` uses `REQUESTS_CA_BUNDLE`. See the [CA certificate reference](/guides/ca-certificates) for per-runtime details. :::: ### Routing Workload Traffic You have two options for directing workload connections through iron-proxy: transparent redirects, or a CONNECT proxy. Transparent redirect is recommended since it requires no changes in workloads and covers all HTTP and HTTPS connections automatically. The CONNECT proxy is an alternative that workloads opt into explicitly. #### Transparent Redirect iptables intercepts outbound HTTP and HTTPS from non-root processes and redirects them to iron-proxy's local listeners. Workloads make ordinary connections with no proxy configuration required. **Redirect ports 80 and 443** to the proxy's listeners: ```bash sudo iptables -t nat -A OUTPUT ! -o lo -p tcp --dport 80 \ -m owner ! --uid-owner root -j REDIRECT --to-ports 8080 sudo iptables -t nat -A OUTPUT ! -o lo -p tcp --dport 443 \ -m owner ! --uid-owner root -j REDIRECT --to-ports 8443 ``` **Lock down direct egress:** ```bash sudo iptables -A OUTPUT -o lo -j ACCEPT sudo iptables -A OUTPUT -m owner --uid-owner root -j ACCEPT sudo iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT sudo iptables -A OUTPUT -j REJECT --reject-with icmp-port-unreachable ``` What each filter rule does: 1. **Loopback.** The REDIRECT rules forward non-root port 80/443 traffic to `127.0.0.1`. This rule allows those packets to reach iron-proxy. 2. **Root egress.** iron-proxy runs as root, so its outbound packets are allowed through. Any other root process (`apt`, `curl` as root, etc.) also passes. 3. **Established connections.** Allows return traffic for connections iron-proxy itself opened. 4. **Default reject.** All other non-root outbound traffic is rejected with an ICMP error. :::warning This setup depends on workloads running as a non-root user. A workload running as root matches rule 2 and bypasses the proxy entirely. Untrusted code must run under `app` or another unprivileged account without sudo access. ::: **Persist the rules** so they survive a reboot: **Debian or Ubuntu:** ```bash sudo apt-get install -y iptables-persistent sudo netfilter-persistent save ``` **RHEL, CentOS, or Amazon Linux:** ```bash sudo yum install -y iptables-services sudo service iptables save sudo systemctl enable iptables ``` ##### Verify ```bash # Allowlisted host: iptables redirects to proxy, proxy forwards — expect 200 sudo -u app curl --max-time 5 https://api.openai.com/v1/models # Blocked host: iptables redirects to proxy, proxy returns 403 sudo -u app curl --max-time 5 https://example.com # Non-80/443 port: hits the filter REJECT rule — expect ICMP error sudo -u app curl --max-time 5 https://example.com:8080 ``` #### CONNECT Proxy Workloads can also connect through iron-proxy explicitly using the `HTTPS_PROXY` environment variable. iron-proxy listens for CONNECT and SOCKS5 tunnel requests on port `1080`. Set these in the workload user's shell profile or the systemd unit that runs the workload: ```bash export HTTPS_PROXY=http://127.0.0.1:1080 export HTTP_PROXY=http://127.0.0.1:1080 export NO_PROXY=127.0.0.1,localhost ``` Most HTTP clients and language SDKs respect these variables. The [SOCKS5 and CONNECT tunnels guide](/guides/socks5-connect) covers per-tool variants. ## Deploy on Daytona Run iron-proxy inside [Daytona](https://www.daytona.io/) sandboxes. You build a custom Docker image with iron-proxy pre-installed, then use Daytona's [declarative builder](https://www.daytona.io/docs/en/declarative-builder/) to launch sandboxes with egress control already configured. :::info Daytona [blocks most egress traffic by default](https://www.daytona.io/docs/network-limits#_top) depending on your billing tier. iron-proxy gives you granular control over which domains are accessible, TLS interception, and request logging on top of Daytona's built-in restrictions. ::: ### How It Works The setup uses a multi-stage Docker image that bundles iron-proxy into a Debian base. When the container starts, the entrypoint script: 1. Generates a CA certificate for TLS interception and installs it into the system trust store. 2. Configures iptables rules to block all non-loopback egress from non-root processes. 3. Points DNS at iron-proxy so all domain resolution resolves to the proxy's listen address. 4. Starts iron-proxy in the background. 5. Removes the `daytona` user's sudo access so workloads cannot escalate to root. 6. Hands off to whatever command you pass in. Because DNS resolves all domains to iron-proxy's listen address and iptables blocks all non-loopback egress from non-root processes, processes inside the sandbox cannot bypass the proxy. The container runs as the unprivileged `daytona` user, and sudo access is revoked after setup completes. ### Prerequisites * A [Daytona](https://www.daytona.io/) account with API access * Python 3.10+ * The Daytona Python SDK: `pip install daytona` ### Setup ::::steps #### Create the iron-proxy Configuration Save this as `iron-proxy.yaml`. This is the configuration file iron-proxy will use at runtime. ```yaml dns: listen: ":53" proxy_ip: "127.0.0.1" upstream_resolver: "8.8.8.8:53" proxy: http_listen: ":80" https_listen: ":443" tls: ca_cert: "/etc/iron-proxy/ca.crt" ca_key: "/etc/iron-proxy/ca.key" transforms: - name: allowlist config: warn: true domains: - "*" log: level: "info" ``` A few things to note about this configuration: * **`dns.proxy_ip`** is set to `127.0.0.1` because iron-proxy runs inside the same sandbox as your workloads. * **`dns.upstream_resolver`** uses `8.8.8.8:53`. Change this if your network requires a different upstream resolver. * **`warn: true`** means non-allowlisted requests are logged but not blocked. Set to `false` to enforce the allowlist. * **`domains: ["*"]`** allows all domains. Replace this with specific domains to restrict egress. For the full set of configuration options, see the [configuration reference](/reference/configuration). #### Create the Entrypoint Script Save this as `entrypoint.sh`. It runs at container startup to configure networking and start iron-proxy. ```bash #!/usr/bin/env bash set -uo pipefail GUARD_FILE="/var/run/iron-proxy-setup-done" if [ -f "$GUARD_FILE" ]; then exec "$@" fi # Generate CA cert/key for TLS MITM and install into system trust store if [ ! -f /etc/iron-proxy/ca.crt ]; then sudo openssl genrsa -out /etc/iron-proxy/ca.key 4096 2>/dev/null sudo openssl req -x509 -new -nodes \ -key /etc/iron-proxy/ca.key \ -sha256 -days 3650 \ -subj "/CN=iron-proxy CA" \ -addext "basicConstraints=critical,CA:TRUE" \ -addext "keyUsage=critical,keyCertSign" \ -out /etc/iron-proxy/ca.crt 2>/dev/null sudo cp /etc/iron-proxy/ca.crt /usr/local/share/ca-certificates/iron-proxy.crt sudo update-ca-certificates 2>/dev/null fi # Block non-root, non-loopback egress sudo iptables -A OUTPUT -o lo -j ACCEPT sudo iptables -A OUTPUT -m owner --uid-owner root -j ACCEPT sudo iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT sudo iptables -A OUTPUT -j REJECT --reject-with icmp-port-unreachable # Point DNS at iron-proxy echo "nameserver 127.0.0.1" | sudo tee /etc/resolv.conf > /dev/null # Start iron-proxy in background sudo --preserve-env setsid bash -c '/usr/local/bin/iron-proxy -config /etc/iron-proxy/config.yaml &>/var/log/iron-proxy.log' & # Add any additional trusted setup here (e.g., cloning repos, # installing packages, fetching credentials) while sudo is # still available. # Drop sudo access for daytona user sudo rm -f /etc/sudoers.d/daytona # Mark setup as complete sudo touch "$GUARD_FILE" # Hand off to whatever the user passes in exec "$@" ``` The entrypoint uses `sudo` to configure iptables, generate the CA, and start iron-proxy as root. Once setup is complete, it removes the `daytona` user's sudoers entry so that your workload cannot escalate to root. This is important: the iptables rules allow traffic from root so that iron-proxy itself can reach upstream servers. If your workload could escalate to root via `sudo`, it would bypass the proxy entirely. :::warning If you do not drop sudo access in the entrypoint, you must do so somewhere in your workload before running untrusted code. Any process that can escalate to root will bypass iron-proxy's iptables rules. ::: The guard file (`/var/run/iron-proxy-setup-done`) ensures the setup logic only runs once. If the entrypoint is invoked again, it skips straight to executing your command. #### Create the Dockerfile Save this as `Dockerfile`. It uses a multi-stage build to pull the iron-proxy binary and bundle it into a Debian base image. The `daytona` user is created with passwordless sudo so the entrypoint can perform privileged setup, then the sudoers entry is removed at the end of the entrypoint. ```dockerfile FROM ironsh/iron-proxy:latest AS iron-proxy FROM debian:trixie-slim RUN apt-get update && apt-get install -y --no-install-recommends \ iptables iproute2 ca-certificates openssl procps sudo \ && rm -rf /var/lib/apt/lists/* COPY --from=iron-proxy /usr/local/bin/iron-proxy /usr/local/bin/iron-proxy COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh RUN mkdir -p /etc/iron-proxy COPY iron-proxy.yaml /etc/iron-proxy/config.yaml RUN useradd -m -s /bin/bash daytona && \ echo "daytona ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/daytona USER daytona ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] ``` #### Create the Sandbox Use the Daytona Python SDK to build the image and launch a sandbox. Save this as `create_sandbox.py`: ```python from daytona import Daytona, DaytonaConfig, Image, CreateSandboxFromImageParams config = DaytonaConfig(api_key="") daytona = Daytona(config) image = Image.from_dockerfile("Dockerfile") sandbox = daytona.create( CreateSandboxFromImageParams(image=image), timeout=0, on_snapshot_create_logs=print, ) print(sandbox) ``` Run it: ```bash python create_sandbox.py ``` Daytona will build the Docker image using its [declarative builder](https://www.daytona.io/docs/en/declarative-builder/), then launch a sandbox from the resulting image. The first build may take longer while the image layers are cached. #### Verify Once the sandbox is running, verify that iron-proxy is intercepting traffic: ```bash curl -sv https://httpbin.org/get 2>&1 | grep "issuer" ``` If iron-proxy is working, the TLS certificate issuer will be `iron-proxy CA` rather than the real upstream issuer. You can also check the iron-proxy logs: ```bash cat /var/log/iron-proxy.log ``` :::: ### Customizing the Allowlist Edit the `domains` array in `iron-proxy.yaml` to restrict which domains are accessible: ```yaml transforms: - name: allowlist config: warn: false domains: - "registry.npmjs.org" - "pypi.org" - "api.github.com" ``` Set `warn` to `false` to block non-allowlisted requests. Rebuild the image and create a new sandbox for changes to take effect. ### Trusting the CA The entrypoint script trusts the CA system-wide via `update-ca-certificates`. Most tools will work without additional configuration. If you run into TLS errors, see the [CA certificate reference](/guides/ca-certificates) for per-runtime details. ### Troubleshooting #### iron-proxy Is Not Running Check the log file: ```bash cat /var/log/iron-proxy.log ``` If the file is empty or missing, the entrypoint script may have failed before starting iron-proxy. Run the entrypoint manually to see errors: ```bash /usr/local/bin/entrypoint.sh bash ``` #### DNS Resolution Fails Verify that `/etc/resolv.conf` points at `127.0.0.1`: ```bash cat /etc/resolv.conf ``` If it was overwritten, the entrypoint may not have run correctly. Check that the container is using the custom entrypoint and not a default one. #### TLS Certificate Errors If you see `certificate signed by unknown authority`, the CA is not trusted by the runtime making the request. Check that `update-ca-certificates` ran successfully during container startup, and ensure the appropriate environment variable is set for your runtime. See [Managing CA Certificates](/guides/ca-certificates) for details. ## Deploy on Amazon ECS Run iron-proxy as a [daemon service](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs_services.html#service_scheduler_daemon) on Amazon ECS. The proxy intercepts all outbound HTTP/HTTPS traffic from workload containers and checks it against a domain allowlist. :::warning Fargate is not supported. Fargate doesn't allow per-container DNS overrides, which iron-proxy requires to intercept traffic. This guide requires ECS with the EC2 launch type. ::: ### How It Works iron-proxy sits on the Docker bridge network. Workload containers point their DNS at iron-proxy, which intercepts lookups, returns its own IP, and terminates TLS using a per-domain leaf certificate minted from an ephemeral CA. Traffic is then checked against your allowlist and forwarded upstream. ``` ┌──────────────────────────────────────────────────────────┐ │ EC2 Instance │ │ │ │ ┌────────────────────────────────────────────────────┐ │ │ │ docker0 bridge │ │ │ │ │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │ │ │ │workload-A│ │workload-B│ │ iron-proxy │ │ │ │ │ │ │ │ │ │ (daemon) │ │ │ │ │ │ dns: ────┼──┼──────────┼─►│ :53 DNS │ │ │ │ │ │ proxy IP│ │ dns: ────┼─►│ :443 HTTPS │ │ │ │ │ │ │ │ proxy IP│ │ :80 HTTP │ │ │ │ │ └──────────┘ └──────────┘ └────────┬─────────┘ │ │ │ └───────────────────────────────────────┼────────────┘ │ │ │ │ │ allowed traffic │ │ ▼ │ │ internet / VPC │ └──────────────────────────────────────────────────────────┘ ``` ### Prerequisites * An ECS cluster with at least one EC2 instance registered * The `aws` CLI configured with credentials * An S3 bucket for the iron-proxy config file * A CloudWatch log group (e.g. `/ecs/iron-proxy`) ### Setup :::steps #### Create the iron-proxy Config Create an `iron-proxy.yaml` file. This controls DNS behavior, the allowlist, and audit logging. ```yaml dns: listen: ":53" proxy_ip: "172.17.0.2" upstream_resolver: "10.0.0.2:53" proxy: http_listen: ":80" https_listen: ":443" tls: ca_cert: "/etc/iron-proxy/ca.crt" ca_key: "/etc/iron-proxy/ca.key" transforms: - name: allowlist config: warn: true domains: - "registry.npmjs.org" - "pypi.org" - "files.pythonhosted.org" ``` A few things to note: * **`dns.upstream_resolver`** must be your VPC DNS resolver. This is always the `.2` address of your VPC CIDR (e.g. `10.0.0.2` for a `10.0.0.0/16` VPC). Port is required. * **`dns.proxy_ip`** must match the IP iron-proxy gets on the Docker bridge. Docker assigns bridge IPs sequentially, and the daemon service (which starts first) will typically get `172.17.0.2`. * **`warn: true`** means all traffic is logged but nothing is blocked. Set to `false` (or remove it) when your allowlist is complete. Upload the config to S3: ```bash aws s3 cp iron-proxy.yaml s3://YOUR_BUCKET/iron-proxy.yaml ``` #### Create the Daemon Task Definition The daemon task has two containers: 1. **iron-proxy-init** generates an ephemeral CA certificate on first boot and writes it to a shared host volume. Skips generation on subsequent restarts. 2. **iron-proxy** waits for the init container to finish, then starts the proxy. Save this as `iron-proxy-daemon.json`: ```json { "family": "iron-proxy-daemon", "networkMode": "bridge", "requiresCompatibilities": ["EC2"], "executionRoleArn": "arn:aws:iam::YOUR_ACCOUNT_ID:role/ecsTaskExecutionRole", "taskRoleArn": "arn:aws:iam::YOUR_ACCOUNT_ID:role/ironProxyTaskRole", "volumes": [ { "name": "iron-ca", "host": { "sourcePath": "/opt/iron-proxy/ca" } } ], "containerDefinitions": [ { "name": "iron-proxy-init", "image": "alpine:latest", "essential": false, "memory": 64, "command": [ "sh", "-c", "if [ -f /etc/iron-proxy/ca.crt ] && [ -f /etc/iron-proxy/ca.key ]; then echo 'CA exists'; exit 0; fi && apk add --no-cache openssl && openssl genrsa -out /etc/iron-proxy/ca.key 4096 && openssl req -x509 -new -nodes -key /etc/iron-proxy/ca.key -sha256 -days 90 -subj '/CN=iron-proxy CA' -addext 'basicConstraints=critical,CA:TRUE' -addext 'keyUsage=critical,keyCertSign' -out /etc/iron-proxy/ca.crt" ], "mountPoints": [ { "sourceVolume": "iron-ca", "containerPath": "/etc/iron-proxy" } ], "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-group": "/ecs/iron-proxy", "awslogs-region": "YOUR_REGION", "awslogs-stream-prefix": "init" } } }, { "name": "iron-proxy", "image": "docker.io/ironsh/iron-proxy:latest", "essential": true, "memory": 256, "dependsOn": [ { "containerName": "iron-proxy-init", "condition": "SUCCESS" } ], "command": [ "-config", "s3://YOUR_BUCKET/iron-proxy.yaml" ], "environment": [ { "name": "AWS_REGION", "value": "YOUR_REGION" } ], "portMappings": [ { "containerPort": 53, "hostPort": 53, "protocol": "udp" }, { "containerPort": 53, "hostPort": 53, "protocol": "tcp" }, { "containerPort": 443, "hostPort": 443, "protocol": "tcp" }, { "containerPort": 80, "hostPort": 80, "protocol": "tcp" } ], "mountPoints": [ { "sourceVolume": "iron-ca", "containerPath": "/etc/iron-proxy" } ], "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-group": "/ecs/iron-proxy", "awslogs-region": "YOUR_REGION", "awslogs-stream-prefix": "daemon" } } } ] } ``` The **task execution role** (`ecsTaskExecutionRole`) needs the standard ECS permissions to pull images and write logs. Attach the managed `AmazonECSTaskExecutionRolePolicy`. The **task role** (`ironProxyTaskRole`) needs S3 read access so iron-proxy can fetch its config at startup: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::YOUR_BUCKET/*" } ] } ``` Both roles must have an `ecs-tasks.amazonaws.com` trust policy: ```json { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Service": "ecs-tasks.amazonaws.com" }, "Action": "sts:AssumeRole" } ] } ``` #### Deploy the Daemon Service Register the task definition and create a daemon service: ```bash aws ecs register-task-definition \ --cli-input-json file://iron-proxy-daemon.json aws ecs create-service \ --cluster YOUR_CLUSTER \ --service-name iron-proxy \ --task-definition iron-proxy-daemon \ --scheduling-strategy DAEMON \ --deployment-configuration \ 'maximumPercent=100,minimumHealthyPercent=0' ``` ECS will place one iron-proxy task on every EC2 instance in the cluster. When new instances join, they get one too. #### Configure Workload Task Definitions Two changes to any workload task definition: 1. Set `dnsServers` to iron-proxy's bridge IP so DNS resolves through the proxy 2. Mount the CA certificate volume so the workload trusts iron-proxy's TLS certificates Here's a minimal test workload that curls `httpbin.org` through iron-proxy every 5 seconds: ```json { "family": "iron-proxy-test", "networkMode": "bridge", "requiresCompatibilities": ["EC2"], "containerDefinitions": [ { "name": "curl-test", "image": "alpine/curl:latest", "essential": true, "memory": 128, "dnsServers": ["172.17.0.2"], "entryPoint": ["sh", "-c"], "command": [ "while true; do curl -sv --cacert /etc/iron-proxy/ca.crt https://httpbin.org/get 2>&1; sleep 5; done" ], "mountPoints": [ { "sourceVolume": "iron-ca", "containerPath": "/etc/iron-proxy", "readOnly": true } ], "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-group": "/ecs/iron-proxy", "awslogs-region": "YOUR_REGION", "awslogs-stream-prefix": "curl-test" } } } ], "volumes": [ { "name": "iron-ca", "host": { "sourcePath": "/opt/iron-proxy/ca" } } ] } ``` Run the test: ```bash aws ecs register-task-definition \ --cli-input-json file://iron-proxy-test.json aws ecs run-task \ --cluster YOUR_CLUSTER \ --task-definition iron-proxy-test ``` #### Verify Watch iron-proxy's audit logs: ```bash aws logs tail /ecs/iron-proxy --prefix daemon --follow ``` You should see a JSON audit entry for every request: ```json { "host": "httpbin.org", "method": "GET", "path": "/get", "action": "allow", "status_code": 200, "duration_ms": 142, "request_transforms": [ { "name": "allowlist", "action": "allow" } ] } ``` ::: ### Rolling Out Start in warn mode, then switch to enforce mode once your allowlist is dialed in. 1. **Start with warn mode.** Set `warn: true` in the allowlist config as shown above. All traffic flows through, and denied requests are logged but not blocked. 2. **Review the audit logs.** They show every domain your workloads contact and whether requests would have been allowed or denied. 3. **Build your allowlist.** Add domains you expect and trust to `iron-proxy.yaml`. 4. **Switch to enforce mode.** Remove `warn: true` (or set it to `false`). Non-allowlisted requests are now blocked. ### Preventing Circumvention :::warning This section is strongly recommended to prevent workloads from bypassing your egress rules. ::: This isn't required to run the demo above. Setting `dnsServers` routes DNS through iron-proxy, but a workload container could still bypass the proxy by connecting to an IP address directly. To prevent this, add `iptables` rules to the EC2 instance that force all outbound traffic from containers through iron-proxy. You'll need to add `iptables` rules similar to the following to your instance user data: ```bash # Get the iron-proxy container IP PROXY_IP=172.17.0.2 # Allow traffic from iron-proxy itself to reach the internet iptables -I FORWARD -s $PROXY_IP -j ACCEPT # Allow established connections (return traffic) iptables -I FORWARD -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT # Block all other outbound traffic from the Docker bridge # except to iron-proxy's ports iptables -A FORWARD -i docker0 -p tcp --dport 80 -d $PROXY_IP -j ACCEPT iptables -A FORWARD -i docker0 -p tcp --dport 443 -d $PROXY_IP -j ACCEPT iptables -A FORWARD -i docker0 -p udp --dport 53 -d $PROXY_IP -j ACCEPT iptables -A FORWARD -i docker0 -p tcp --dport 53 -d $PROXY_IP -j ACCEPT iptables -A FORWARD -i docker0 -j DROP ``` This ensures that workload containers can only reach the network through iron-proxy. Direct connections to external IPs are dropped at the host level. ### Updating the Config Edit `iron-proxy.yaml`, re-upload to S3, and force a redeployment: ```bash aws s3 cp iron-proxy.yaml s3://YOUR_BUCKET/iron-proxy.yaml aws ecs update-service \ --cluster YOUR_CLUSTER \ --service iron-proxy \ --force-new-deployment ``` iron-proxy fetches the config from S3 at startup, so a redeployment picks up the new config. ### CA Certificate Rotation The init container generates a 90-day CA on first boot and persists it to the host at `/opt/iron-proxy/ca`. To rotate, delete the files from the host and restart the daemon service: ```bash # On the EC2 instance: sudo rm /opt/iron-proxy/ca/ca.crt /opt/iron-proxy/ca/ca.key # Then force a redeployment: aws ecs update-service \ --cluster YOUR_CLUSTER \ --service iron-proxy \ --force-new-deployment ``` Workload containers pick up the new CA from the shared volume on their next restart. ### Trusting the CA Workload containers need to trust iron-proxy's CA certificate. Mount the CA volume and configure your runtime: | Runtime | Environment Variable or Flag | | ----------------- | -------------------------------------------- | | curl | `--cacert /etc/iron-proxy/ca.crt` | | Most languages | `SSL_CERT_FILE=/etc/iron-proxy/ca.crt` | | Node.js | `NODE_EXTRA_CA_CERTS=/etc/iron-proxy/ca.crt` | | Python (requests) | `REQUESTS_CA_BUNDLE=/etc/iron-proxy/ca.crt` | Alternatively, you can bake the CA certificate directly into your workload Dockerfile. In that case, you'll need to pre-generate the CA rather than using the init container. For more details, see the [CA certificate reference](/guides/ca-certificates). ### Secrets iron-proxy can source secrets directly from AWS Secrets Manager using the `aws_sm` source type. This avoids needing to pass secrets through ECS environment variables: ```yaml transforms: - name: secrets config: secrets: - source: type: aws_sm secret_id: "arn:aws:secretsmanager:YOUR_REGION:YOUR_ACCOUNT_ID:secret:my-api-key" region: "YOUR_REGION" ttl: 15m replace: proxy_value: "pk-proxy-xxx" match_headers: ["Authorization"] rules: - host: "api.example.com" ``` The `ttl` field controls how long iron-proxy caches the secret value before refreshing from Secrets Manager. The task role (`ironProxyTaskRole`) needs `secretsmanager:GetSecretValue` permission on the secret ARN. You can also use the `env` source with [ECS Secrets Manager integration](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/specifying-sensitive-data-secrets.html) to inject secrets as environment variables at task launch: ```json "secrets": [ { "name": "SOME_API_KEY", "valueFrom": "arn:aws:secretsmanager:YOUR_REGION:YOUR_ACCOUNT_ID:secret:iron-proxy/api-key" } ] ``` See the [configuration reference](/reference/configuration#secrets) for the full secrets configuration format. ### Troubleshooting #### iron-proxy Container Exits Immediately Check that `AWS_REGION` is set in the container environment. Without it, the S3 config fetch fails silently. #### Workloads Get "Connection Refused" Verify `dns.proxy_ip` in the config matches iron-proxy's actual IP on the Docker bridge: ```bash sudo docker inspect $(sudo docker ps -q --filter name=iron-proxy) \ --format '{{.NetworkSettings.Networks.bridge.IPAddress}}' ``` #### Upstream TLS Errors (x509) iron-proxy verifies upstream server certificates against its system CA bundle. If the upstream chain includes a root not in Alpine's trust store, you'll see `certificate signed by unknown authority`. Fix by adding the missing root CA to the iron-proxy image. #### Port 53 Conflict If `systemd-resolved` is running on the EC2 host, it binds port 53 and iron-proxy can't start. Disable it in your instance user data: ```bash systemctl disable --now systemd-resolved ``` ## Deploy on Freestyle Run iron-proxy inside [Freestyle](https://freestyle.sh) VMs. You create a VM snapshot with iron-proxy pre-installed, then use that snapshot to launch ephemeral VMs with egress control already configured. ### How It Works The setup has two phases: 1. **Snapshot creation:** A one-time script builds a Freestyle VM that installs iron-proxy, generates a CA certificate, trusts it system-wide, configures DNS to resolve through the proxy, and sets up iptables rules to force all traffic through the proxy. Freestyle snapshots the result. 2. **VM launch:** New VMs boot from the snapshot with iron-proxy already running. DNS queries resolve through iron-proxy, which checks them against your allowlist and terminates TLS using per-domain leaf certificates. iptables rules prevent workloads from bypassing the proxy. Because iron-proxy owns DNS inside the VM and iptables blocks direct outbound connections from non-root processes, all traffic flows through the proxy automatically. There is no need for per-process configuration or custom environment variables. ### Prerequisites * A [Freestyle](https://freestyle.sh) account with API access * Node.js 18+ * A `FREESTYLE_API_KEY` environment variable set in your shell ### Setup :::steps #### Install the Freestyle SDK ```bash npm install freestyle ``` #### Create the Snapshot Script Save this as `create-snapshot.mjs`. It builds a Debian VM, installs iron-proxy, generates a trusted CA, and snapshots the result. ```javascript import { freestyle, VmSpec, VmBaseImage } from "freestyle"; const IRON_PROXY_VERSION = process.env.IRON_PROXY_VERSION || "latest"; // iron-proxy configuration const ironProxyConfig = ` dns: listen: ":53" proxy_ip: "127.0.0.1" upstream_resolver: "8.8.8.8:53" proxy: http_listen: ":80" https_listen: ":443" tls: ca_cert: "/etc/iron-proxy/ca.crt" ca_key: "/etc/iron-proxy/ca.key" transforms: - name: allowlist config: warn: false domains: - "api.github.com" - "github.com" - "objects.githubusercontent.com" - "httpbin.org" log: level: "info" `.trimStart(); // Oneshot script: download iron-proxy, generate CA, trust CA, stop systemd-resolved, // create unprivileged user, set up iptables const installScript = `#!/bin/bash set -euo pipefail # Resolve version VERSION="${IRON_PROXY_VERSION}" if [ "$VERSION" = "latest" ]; then VERSION=$(curl -fsSL https://api.github.com/repos/ironsh/iron-proxy/releases/latest | jq -r '.tag_name | ltrimstr("v")') fi echo "Installing iron-proxy v$VERSION" # Download and install binary curl -fsSL -o /tmp/iron-proxy.tgz \\ "https://github.com/ironsh/iron-proxy/releases/download/v\${VERSION}/iron-proxy_\${VERSION}_linux_amd64.tar.gz" tar -xzf /tmp/iron-proxy.tgz -C /tmp mv /tmp/iron-proxy /usr/local/bin/iron-proxy chmod +x /usr/local/bin/iron-proxy rm -f /tmp/iron-proxy.tgz # Generate CA for TLS interception mkdir -p /etc/iron-proxy openssl genrsa -out /etc/iron-proxy/ca.key 2048 2>/dev/null openssl req -x509 -new -nodes \\ -key /etc/iron-proxy/ca.key \\ -sha256 -days 365 \\ -subj "/CN=iron-proxy CA" \\ -addext "basicConstraints=critical,CA:TRUE" \\ -addext "keyUsage=critical,keyCertSign" \\ -out /etc/iron-proxy/ca.crt 2>/dev/null # Trust the CA system-wide cp /etc/iron-proxy/ca.crt /usr/local/share/ca-certificates/iron-proxy-ca.crt update-ca-certificates # Stop systemd-resolved to free port 53 systemctl stop systemd-resolved || true systemctl disable systemd-resolved || true # Route DNS through the proxy echo "nameserver 127.0.0.1" > /etc/resolv.conf # Create an unprivileged user for running workloads. # This is required: iptables rules below allow outbound traffic from root # (which iron-proxy runs as) and reject everything else. If workloads run # as root, they can bypass the proxy entirely. useradd -m -s /bin/bash workload # Set up iptables rules to force traffic through the proxy. # - Allow all loopback traffic (needed for proxy communication). # - Allow outbound traffic from root (iron-proxy runs as root). # - Allow established/related connections (return traffic for accepted connections). # - Reject everything else: non-root processes cannot reach the network directly. iptables -A OUTPUT -o lo -j ACCEPT iptables -A OUTPUT -m owner --uid-owner root -j ACCEPT iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT iptables -A OUTPUT -j REJECT --reject-with icmp-port-unreachable `; async function main() { const baseImage = new VmBaseImage("FROM debian:trixie-slim").runCommands( "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates openssl jq iptables sudo" ); const spec = new VmSpec() .baseImage(baseImage) .additionalFiles({ "/etc/iron-proxy/config.yaml": { content: ironProxyConfig }, "/usr/local/bin/install-iron-proxy.sh": { content: installScript }, }) .systemdService({ name: "install-iron-proxy", mode: "oneshot", exec: ["bash /usr/local/bin/install-iron-proxy.sh"], wantedBy: ["multi-user.target"], remainAfterExit: true, timeoutSec: 120, }) .systemdService({ name: "iron-proxy", mode: "service", exec: ["/usr/local/bin/iron-proxy -config /etc/iron-proxy/config.yaml"], after: ["install-iron-proxy.service"], requires: ["install-iron-proxy.service"], restartPolicy: { policy: "on-failure", restartSec: 5, }, }); console.log("Creating iron-proxy VM and snapshotting..."); const { vm, snapshotId } = await freestyle.vms.create({ snapshot: spec, persistence: { type: "ephemeral" }, }); // Verify everything is running const status = await vm.exec("systemctl status iron-proxy --no-pager"); console.log("\niron-proxy service status:"); console.log(status.stdout); const resolv = await vm.exec("cat /etc/resolv.conf"); console.log("DNS configuration:"); console.log(resolv.stdout); await vm.stop(); console.log(`\nSnapshot ID: ${snapshotId}`); console.log( "Done. Use this snapshotId to create ephemeral VMs with iron-proxy pre-configured." ); } main().catch((err) => { console.error(err); process.exit(1); }); ``` A few things to note about this script: * **`dns.proxy_ip`** is set to `127.0.0.1` because iron-proxy runs inside the same VM as your workloads. * **`dns.upstream_resolver`** uses `8.8.8.8:53`. Change this if your network requires a different upstream resolver. * **`warn: false`** means non-allowlisted requests are blocked immediately. Set to `true` while building your allowlist. * **`domains`** includes GitHub (needed for the install script to download iron-proxy) and `httpbin.org` for testing. Replace these with the domains your workloads need before creating the snapshot. * The install script disables `systemd-resolved` and points `/etc/resolv.conf` at `127.0.0.1` so all DNS goes through iron-proxy. * The CA is trusted system-wide via `update-ca-certificates`. * A `workload` user is created for running untrusted code. This is required because the iptables rules allow outbound traffic from root. If workloads run as root, they can bypass the proxy. * The iptables rules allow loopback and root-owned traffic, then reject everything else. This forces all non-root outbound traffic through the proxy. #### Bootstrap the Snapshot Run the script to create your snapshot. This requires a `FREESTYLE_API_KEY` in your environment: ```bash export FREESTYLE_API_KEY="your-api-key" node create-snapshot.mjs ``` The first run will take longer than usual because Freestyle needs to build the base image from scratch. You should expect to see output like this: ``` Creating iron-proxy VM and snapshotting... VM creation is taking longer than expected. This usually happens when there's a cache miss on your vm's base snapshot. Subsequent vm creations with this configuration will likely be much faster. iron-proxy service status: ● iron-proxy.service - iron-proxy Loaded: loaded (/etc/systemd/system/iron-proxy.service; enabled; preset: enabled) Active: active (running) since Wed 2026-04-08 19:50:52 UTC; 4s ago Invocation: 54e29589b0bd4aaa827f7bd0ef8370d2 Main PID: 1984 (iron-proxy) Tasks: 10 (limit: 9551) Memory: 3M (peak: 3.6M) CPU: 51ms CGroup: /system.slice/iron-proxy.service └─1984 /usr/local/bin/iron-proxy -config /etc/iron-proxy/config.yaml ... DNS configuration: nameserver 127.0.0.1 Snapshot ID: sc-gogdcl41ilq3jabxytjn Done. Use this snapshotId to create ephemeral VMs with iron-proxy pre-configured. ``` Save the snapshot ID. You will use it to launch new VMs. #### Launch VMs From the Snapshot Use the snapshot ID to create ephemeral VMs with iron-proxy already running: ```javascript import { freestyle } from "freestyle"; const { vm } = await freestyle.vms.create({ snapshotId: "sc-abc123...", persistence: { type: "ephemeral" }, }); // Run commands as the unprivileged workload user const result = await vm.exec("sudo -u workload curl -s https://httpbin.org/get"); console.log(result.stdout); await vm.stop(); ``` #### Verify Check that iron-proxy is intercepting traffic inside a running VM: ```bash # Inside the VM (via vm.exec or SSH) systemctl status iron-proxy ``` You should see the service active and running. Make a test request to confirm: ```bash curl -sv https://httpbin.org/get 2>&1 | grep "issuer" ``` If iron-proxy is working, the TLS certificate issuer will be `iron-proxy CA` rather than the real upstream issuer. ::: ### Customizing the Allowlist Edit the `domains` array in the `ironProxyConfig` string before creating the snapshot: ```yaml transforms: - name: allowlist config: warn: false domains: - "registry.npmjs.org" - "pypi.org" - "api.github.com" ``` To update an existing deployment, re-run `create-snapshot.mjs` to produce a new snapshot ID, then update your application to use the new ID. For the full set of configuration options, see the [configuration reference](/reference/configuration). ### Egress Control With iptables The snapshot script sets up iptables rules that force all outbound traffic through iron-proxy. This uses the `xt_owner` kernel module, which provides the `--uid-owner` match: it allows traffic from root (which iron-proxy runs as) while rejecting everything else. The rules are: ```bash # Allow all loopback traffic (workloads talk to iron-proxy on 127.0.0.1) iptables -A OUTPUT -o lo -j ACCEPT # Allow outbound traffic from root (iron-proxy runs as root) iptables -A OUTPUT -m owner --uid-owner root -j ACCEPT # Allow return traffic for established connections iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT # Reject everything else iptables -A OUTPUT -j REJECT --reject-with icmp-port-unreachable ``` These rules mean that non-root processes cannot reach the network directly. Even if a workload hardcodes an IP address or uses its own DNS resolver, the traffic is rejected at the kernel level. #### Running Workloads as an Unprivileged User The iptables rules allow all traffic from root, so workloads **must** run as a non-root user to prevent circumvention. The snapshot script creates a `workload` user for this purpose. Run all untrusted code as this user: ```javascript // Run commands as the unprivileged workload user const result = await vm.exec("sudo -u workload your-command-here"); ``` If a workload runs as root, it can bypass the proxy entirely: its traffic matches the `--uid-owner root` rule and goes straight to the internet. Always ensure untrusted code runs under the `workload` user or another non-root account. #### Freestyle's Built-In Egress Control If you use Freestyle's [serverless runs](https://docs.freestyle.sh/v2/serverless/runs), you can use Freestyle's built-in network permissions instead of iron-proxy. Serverless runs support allow and deny rules that restrict which domains the run can access at the platform level. When an allow rule is specified, only whitelisted domains are accessible and all other requests are blocked. This is a simpler alternative if you do not need iron-proxy's TLS interception, logging, or transform features. ### Trusting the CA The snapshot script trusts the CA system-wide and sets common runtime environment variables. Most tools will work without additional configuration. If you run into TLS errors, see the [CA certificate reference](/guides/ca-certificates) for per-runtime details. ### Troubleshooting #### iron-proxy Service Is Not Running Check the service logs: ```bash journalctl -u iron-proxy --no-pager -n 50 ``` If the install oneshot failed, check that too: ```bash journalctl -u install-iron-proxy --no-pager -n 50 ``` Common causes: network issues during the iron-proxy binary download, or a version string that does not match a GitHub release. #### DNS Resolution Fails Verify that `/etc/resolv.conf` points at `127.0.0.1`: ```bash cat /etc/resolv.conf ``` If it was overwritten (e.g., by DHCP), the snapshot's `systemd-resolved` disable may not have taken effect. Re-run the snapshot creation. #### TLS Certificate Errors If you see `certificate signed by unknown authority`, the CA is not trusted by the runtime making the request. Check that `update-ca-certificates` ran successfully during snapshot creation, and ensure the appropriate environment variable is set for your runtime. See [Managing CA Certificates](/guides/ca-certificates) for details. ## Deploy on GitHub Actions The [iron-proxy GitHub Action](https://github.com/ironsh/iron-proxy-action) runs iron-proxy inside your GitHub Actions workflows. It intercepts all outbound HTTP/HTTPS traffic and checks it against a domain allowlist. ### Setup :::steps #### Create an Egress Rules File Add `egress-rules.yaml` to your repo root: ```yaml domains: - "registry.npmjs.org" - "*.npmjs.org" - "nodejs.org" - "*.nodejs.org" ``` GitHub Actions infrastructure domains (`github.com`, `*.githubusercontent.com`, etc.) are included automatically. You do not need to add them. #### Add the Action to Your Workflow ```yaml jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ironsh/iron-proxy-action@v1 with: egress-rules: egress-rules.yaml warn: true # Replace with your build/test steps - run: npm ci - run: npm test # Always show the traffic summary, even if earlier steps fail - uses: ironsh/iron-proxy-action/summary@v1 if: always() ``` The `ironsh/iron-proxy-action/summary@v1` step prints a table of every domain your job contacted: how many requests were allowed, how many were denied, and where to tighten or loosen your rules. #### Review the Summary After the workflow runs, check the job summary. It shows every endpoint your pipeline attempted to reach and whether the request was allowed or denied. ![Egress summary showing allowed and denied domains](/images/egress-summary.png) ::: ### Rolling Out Start in warn mode, then switch to enforce mode once your allowlist is dialed in. 1. **Start with warn mode.** Set `warn: true` as shown above. Nothing breaks: all traffic flows through, and denied requests are logged but not blocked. 2. **Check the summary step.** It shows every domain your job contacted and whether it would have been allowed or denied. 3. **Update your allowlist.** Add denied domains you expect and trust to `egress-rules.yaml`. 4. **Switch to enforce mode.** Remove `warn: true` (or set it to `false`). Non-allowlisted requests are now blocked. ### Egress Rules Format The `egress-rules.yaml` file corresponds to the `allowlist` transform in the full [iron-proxy configuration](/reference/configuration). It supports three types of rules. #### Domains Glob patterns for allowed hostnames. `*.example.com` matches any subdomain and `example.com` itself. ```yaml domains: - "api.openai.com" - "*.anthropic.com" - "registry.npmjs.org" ``` #### CIDRs IP ranges for network-level allowlisting. ```yaml cidrs: - "10.0.0.0/8" ``` #### Fine-Grained Rules Rules with method and path restrictions for tighter control. ```yaml rules: - domain: "api.example.com" methods: ["GET", "POST"] paths: ["/v1/*"] ``` You can combine all three in a single file: ```yaml domains: - "registry.npmjs.org" - "*.golang.org" cidrs: - "10.0.0.0/8" rules: - domain: "api.example.com" methods: ["GET"] paths: ["/v1/health"] ``` ### Action Inputs | Input | Default | Description | | ------------------- | ------------------- | ---------------------------------------------------------------- | | `version` | `latest` | iron-proxy version to install | | `egress-rules` | `egress-rules.yaml` | Path to your egress rules file | | `warn` | `false` | Log denied requests without blocking them | | `disable-sudo` | `true` | Revoke sudo so subsequent steps cannot bypass the proxy | | `disable-docker` | `true` | Revoke Docker access so subsequent steps cannot bypass the proxy | | `upstream-resolver` | `8.8.8.8:53` | Upstream DNS resolver | #### Summary Action Inputs | Input | Default | Description | | ----------------- | ------------------------- | -------------------------------------------------------- | | `log-file` | `/var/log/iron-proxy.log` | Path to the iron-proxy log file | | `show-full-paths` | `false` | Show per-path request breakdown in a collapsible section | ### How It Works The action: 1. Downloads and installs iron-proxy 2. Generates an ephemeral CA certificate and trusts it system-wide 3. Redirects all DNS to the proxy and locks down outbound traffic with iptables 4. Revokes sudo and Docker access so subsequent steps cannot bypass the proxy All outbound HTTP/HTTPS traffic is routed through iron-proxy, which terminates TLS and checks every request against your allowlist before forwarding it upstream. ### Security Considerations The action revokes sudo and Docker access by default. This prevents subsequent workflow steps from modifying iptables rules, DNS config, or running containers that bypass the proxy. On GitHub-hosted runners, jobs have sudo access, so an attacker who gains code execution *before* the proxy step can circumvent enforcement. For stronger guarantees, use self-hosted runners with hypervisor-level network controls. Against the common supply chain threats (compromised packages, malicious post-install scripts, rogue build plugins), this action raises the bar considerably. ## Deploy on Kubernetes Run iron-proxy inside a Kubernetes cluster as a Deployment behind a Service with a fixed ClusterIP. Workload pods point their DNS at that ClusterIP, which routes lookups and TLS traffic through the proxy. :::info This layout puts iron-proxy on the pod network. It works on any conformant Kubernetes distribution, including managed offerings like GKE, EKS, and AKS. The "Preventing Circumvention" section below describes how to lock down egress with NetworkPolicies so workloads cannot bypass the proxy. ::: ### How It Works iron-proxy runs in its own namespace. A Service with a fixed ClusterIP exposes DNS on port 53 and TLS on ports 80 and 443. Workload pods set `dnsPolicy: None` and list the proxy Service IP as their nameserver. Every DNS lookup returns the proxy's IP, so every HTTP and HTTPS connection terminates at iron-proxy. The proxy then checks the request against your allowlist, swaps any tokens for their upstream values, and forwards the request. ``` ┌──────────────────────────────────────────────────────────────┐ │ Kubernetes cluster │ │ │ │ ┌─────────────────────┐ ┌───────────────────────┐ │ │ │ workload pod │ │ iron-proxy pod │ │ │ │ │ │ │ │ │ │ dnsPolicy: None │ │ :53 DNS │ │ │ │ dnsConfig: │ │ :80 HTTP │ │ │ │ nameservers: │ │ :443 HTTPS MITM │ │ │ │ - ──────┼──────────┼─► │ │ │ │ │ Service │ │ │ │ │ curls httpbin.org ──┼──────────┼─► allowlist + secret │ │ │ │ │ ClusterIP│ transforms │ │ │ └─────────────────────┘ └───────────┬───────────┘ │ │ │ │ │ allowed traffic │ │ ▼ │ │ internet │ └──────────────────────────────────────────────────────────────┘ ``` ### Prerequisites * A Kubernetes cluster with `kubectl` configured * An unused IP inside the cluster Service CIDR that you can reserve for iron-proxy * `openssl` for generating the CA certificate ### Setup ::::steps #### Create The Namespace Everything lives in a dedicated `iron-proxy` namespace so the proxy, its config, and the example workload are easy to inspect and tear down together. ```yaml apiVersion: v1 kind: Namespace metadata: name: iron-proxy ``` ```bash kubectl apply -f 01-namespace.yaml ``` #### Generate And Load The CA iron-proxy mints a per-domain leaf certificate for each upstream host. It needs a CA certificate and private key to sign those leaves. Generate a long-lived CA and load it into the cluster as a Secret: ```bash openssl genrsa -out ca.key 4096 openssl req -x509 -new -nodes \ -key ca.key -sha256 -days 3650 \ -subj "/CN=iron-proxy CA" \ -addext "basicConstraints=critical,CA:TRUE" \ -addext "keyUsage=critical,keyCertSign" \ -out ca.crt kubectl -n iron-proxy create secret generic iron-proxy-ca \ --from-file=ca.crt=ca.crt \ --from-file=ca.key=ca.key ``` :::warning Keep `ca.key` out of source control. Any holder of this key can mint certificates that workloads will trust. See the [CA certificate reference](/guides/ca-certificates) for rotation guidance. ::: #### Reserve A Service IP And Create The Service Workloads need a stable DNS nameserver. Pick a free address inside your cluster Service CIDR and reserve it as the iron-proxy Service ClusterIP. The rest of this guide uses `192.168.194.130`. Replace it with an IP that fits your cluster. ```yaml apiVersion: v1 kind: Service metadata: name: iron-proxy namespace: iron-proxy spec: type: ClusterIP clusterIP: 192.168.194.130 selector: app: iron-proxy ports: - name: dns-udp port: 53 targetPort: dns-udp protocol: UDP - name: dns-tcp port: 53 targetPort: dns-tcp protocol: TCP - name: http port: 80 targetPort: http - name: https port: 443 targetPort: https - name: tunnel port: 8080 targetPort: tunnel ``` ```bash kubectl apply -f 03-proxy-service.yaml ``` #### Load The Upstream Secret The real secret lives in a Kubernetes Secret that is mounted only on the iron-proxy pod. Workloads never see it. Create it imperatively so the value stays out of source control: ```bash kubectl -n iron-proxy create secret generic iron-proxy-upstream-secrets \ --from-literal=HTTPBIN_API_KEY=real-secret-value-abc123 ``` #### Create The Proxy ConfigMap The ConfigMap holds the iron-proxy YAML config. `dns.proxy_ip` must match the Service ClusterIP so lookups return an address the cluster routes back to iron-proxy. `dns.passthrough` keeps in-cluster DNS names (anything under `*.cluster.local` or `*.svc`) working by forwarding them to the upstream resolver unchanged. ```yaml apiVersion: v1 kind: ConfigMap metadata: name: iron-proxy-config namespace: iron-proxy data: proxy.yaml: | dns: listen: ":53" proxy_ip: "192.168.194.130" passthrough: - "*.cluster.local" - "*.svc" proxy: http_listen: ":80" https_listen: ":443" tunnel_listen: ":8080" max_request_body_bytes: 1048576 tls: ca_cert: "/etc/iron-proxy/ca.crt" ca_key: "/etc/iron-proxy/ca.key" cert_cache_size: 1000 leaf_cert_expiry_hours: 72 transforms: - name: allowlist config: domains: - "httpbin.org" - name: secrets config: secrets: - source: type: env var: HTTPBIN_API_KEY proxy_value: "proxy-httpbin-token" match_headers: ["Authorization"] require: true rules: - host: "httpbin.org" log: level: "info" ``` ```bash kubectl apply -f 05-proxy-config.yaml ``` This config does two things: 1. **`allowlist`** blocks every host except `httpbin.org`. Add the domains your workloads actually need. 2. **`secrets`** swaps the placeholder token `proxy-httpbin-token` in the `Authorization` header for the real value of `HTTPBIN_API_KEY`, but only for requests to `httpbin.org`. The workload never holds the real secret. See the [configuration reference](/reference/configuration) for the full set of transforms and options. #### Deploy iron-proxy The Deployment runs iron-proxy with the ConfigMap mounted as its config file, the CA Secret mounted at `/etc/iron-proxy/ca.crt` and `/etc/iron-proxy/ca.key`, and the upstream Secret injected as environment variables. The Service load-balances across every pod that matches the selector, so running multiple replicas is a drop-in change: bump `replicas` and let the RollingUpdate strategy keep at least one pod serving traffic during config changes. All replicas share the same CA, ConfigMap, and upstream Secret, so behavior is identical across pods. Each replica does maintain its own in-memory leaf-cert cache, which means a small amount of duplicate signing work when the same upstream hits different pods. ```yaml apiVersion: apps/v1 kind: Deployment metadata: name: iron-proxy namespace: iron-proxy labels: app: iron-proxy spec: replicas: 2 strategy: type: RollingUpdate rollingUpdate: maxSurge: 1 maxUnavailable: 0 selector: matchLabels: app: iron-proxy template: metadata: labels: app: iron-proxy spec: containers: - name: iron-proxy image: ironsh/iron-proxy:latest args: ["-config", "/etc/iron-proxy/proxy.yaml"] ports: - name: dns-udp containerPort: 53 protocol: UDP - name: dns-tcp containerPort: 53 protocol: TCP - name: http containerPort: 80 - name: https containerPort: 443 - name: tunnel containerPort: 8080 envFrom: - secretRef: name: iron-proxy-upstream-secrets volumeMounts: - name: config mountPath: /etc/iron-proxy/proxy.yaml subPath: proxy.yaml readOnly: true - name: ca mountPath: /etc/iron-proxy/ca.crt subPath: ca.crt readOnly: true - name: ca mountPath: /etc/iron-proxy/ca.key subPath: ca.key readOnly: true resources: requests: cpu: 100m memory: 128Mi limits: cpu: 1000m memory: 512Mi volumes: - name: config configMap: name: iron-proxy-config - name: ca secret: secretName: iron-proxy-ca ``` ```bash kubectl apply -f 06-proxy-deployment.yaml kubectl -n iron-proxy rollout status deploy/iron-proxy ``` #### Run A Test Workload The test workload curls `httpbin.org` every few seconds using a placeholder token in the `Authorization` header. iron-proxy swaps the token for the real key before forwarding. `httpbin.org` echoes request headers back, so the workload log shows the swapped value. A second curl to `example.com` confirms that non-allowlisted hosts get blocked. `dnsPolicy: None` plus `dnsConfig.nameservers` points the workload at the proxy Service IP. Mounting `ca.crt` lets curl trust the leaf certificate iron-proxy mints for `httpbin.org`. ```yaml apiVersion: v1 kind: ConfigMap metadata: name: iron-proxy-workload-tokens namespace: iron-proxy data: HTTPBIN_PROXY_TOKEN: proxy-httpbin-token --- apiVersion: apps/v1 kind: Deployment metadata: name: iron-proxy-workload namespace: iron-proxy labels: app: iron-proxy-workload spec: replicas: 1 selector: matchLabels: app: iron-proxy-workload template: metadata: labels: app: iron-proxy-workload spec: dnsPolicy: None dnsConfig: nameservers: - 192.168.194.130 containers: - name: workload image: curlimages/curl:8.10.1 envFrom: - configMapRef: name: iron-proxy-workload-tokens env: - name: SLEEP_SECONDS value: "5" command: ["/bin/sh", "-c"] args: - | set -u while true; do echo "--- httpbin /headers (expect swapped Authorization) ---" curl --fail-with-body -sS \ --cacert /etc/iron-proxy/ca.crt \ -H "Authorization: Bearer ${HTTPBIN_PROXY_TOKEN}" \ https://httpbin.org/headers || true echo echo "--- blocked destination (expect 403 from iron-proxy) ---" curl -sS -o /dev/null -w "status=%{http_code}\n" \ --cacert /etc/iron-proxy/ca.crt \ https://example.com/ || true sleep "${SLEEP_SECONDS}" done volumeMounts: - name: ca mountPath: /etc/iron-proxy/ca.crt subPath: ca.crt readOnly: true volumes: - name: ca secret: secretName: iron-proxy-ca items: - key: ca.crt path: ca.crt ``` ```bash kubectl apply -f 07-workload-deployment.yaml ``` #### Verify Tail the workload log. The response body from `httpbin.org/headers` should show `Authorization: Bearer real-secret-value-abc123`, confirming iron-proxy swapped the placeholder for the real secret. The second curl to `example.com` should return `status=403`. ```bash kubectl -n iron-proxy logs -f deploy/iron-proxy-workload ``` Then tail the proxy log for the audit record: ```bash kubectl -n iron-proxy logs -f deploy/iron-proxy ``` Each request produces a JSON entry like: ```json { "host": "httpbin.org", "method": "GET", "path": "/headers", "action": "allow", "status_code": 200, "duration_ms": 142, "request_transforms": [ { "name": "allowlist", "action": "allow" }, { "name": "secrets", "action": "replace" } ] } ``` :::: ### Rolling Out Start in warn mode so traffic keeps flowing while you discover what your workloads actually need. Add `warn: true` to the allowlist transform, roll out the ConfigMap, and watch the audit log for denied requests. Once the allowlist covers every domain you see, remove `warn: true` (or set it to `false`) to switch to enforce mode. ```yaml transforms: - name: allowlist config: warn: true domains: - "httpbin.org" ``` Apply the change with `kubectl apply -f 05-proxy-config.yaml` and restart the proxy so it picks up the new config: ```bash kubectl -n iron-proxy rollout restart deploy/iron-proxy ``` ### Preventing Circumvention :::warning This section is strongly recommended. Without it, a workload can skip DNS and connect to an external IP address directly, bypassing the proxy. ::: Use a NetworkPolicy to restrict egress from workload pods so they can only reach iron-proxy. Apply this policy in the namespace where your workloads run: ```yaml apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: workload-egress-via-iron-proxy namespace: iron-proxy spec: podSelector: matchLabels: app: iron-proxy-workload policyTypes: - Egress egress: - to: - podSelector: matchLabels: app: iron-proxy ports: - protocol: UDP port: 53 - protocol: TCP port: 53 - protocol: TCP port: 80 - protocol: TCP port: 443 - protocol: TCP port: 8080 ``` This requires a CNI that enforces NetworkPolicies. Calico, Cilium, and most managed offerings qualify. ### Updating The Config Edit the ConfigMap, apply it, and restart the proxy to pick up the new config: ```bash kubectl apply -f 05-proxy-config.yaml kubectl -n iron-proxy rollout restart deploy/iron-proxy ``` With `replicas: 2` and `maxUnavailable: 0`, Kubernetes keeps at least one pod serving traffic throughout the rollout. Scale to more replicas if you need headroom. ### Trusting The CA Workload pods need to trust iron-proxy's CA. The simplest approach is to mount the `iron-proxy-ca` Secret (the `ca.crt` key only) and point your runtime at it: | Runtime | Environment Variable Or Flag | | ----------------- | -------------------------------------------- | | curl | `--cacert /etc/iron-proxy/ca.crt` | | Most languages | `SSL_CERT_FILE=/etc/iron-proxy/ca.crt` | | Node.js | `NODE_EXTRA_CA_CERTS=/etc/iron-proxy/ca.crt` | | Python (requests) | `REQUESTS_CA_BUNDLE=/etc/iron-proxy/ca.crt` | If you control the workload image, you can also bake the CA into the system trust store at image build time. See the [CA certificate reference](/guides/ca-certificates) for per-runtime details. ### Troubleshooting #### Workloads Get "Connection Refused" or DNS Timeouts Check that the Service ClusterIP in `03-proxy-service.yaml` matches `dns.proxy_ip` in the ConfigMap. The two values must be identical. If you picked an IP that is already in use, `kubectl apply` on the Service fails with `clusterIP is already allocated`. Pick a different IP and update both files. #### iron-proxy Pod Crashes With "Permission Denied" on Port 53 Some Kubernetes distributions restrict binding to privileged ports (below 1024) inside containers. If this hits you, either set `securityContext.capabilities.add: ["NET_BIND_SERVICE"]` on the container or move the DNS listener to a high port and adjust `dnsConfig` on workloads accordingly. #### Upstream TLS Errors (x509) iron-proxy verifies upstream server certificates against its system CA bundle. If the upstream chain includes a root not present in iron-proxy's image, requests fail with `certificate signed by unknown authority`. Add the missing root CA to the iron-proxy image or a volume-mounted bundle. #### In-Cluster DNS Broken for Workload Pods `dns.passthrough` in the ConfigMap must include the suffixes used by your cluster's service DNS. The defaults (`*.cluster.local` and `*.svc`) cover kube-dns and CoreDNS out of the box. If you use a custom cluster domain, add it to `passthrough`. #### Secret Swap Not Happening If `httpbin.org` echoes back the placeholder token instead of the real secret, check that `HTTPBIN_API_KEY` is set on the iron-proxy pod and that the `Authorization` header in the outbound request exactly matches the placeholder. The secrets transform is literal, not a substring match. Inspect the pod env with: ```bash kubectl -n iron-proxy exec deploy/iron-proxy -- env | grep HTTPBIN ``` ## Deployment Methods iron-proxy is a single binary, and how you place it determines the tradeoffs between circumvention resistance, operational complexity, and isolation. This page covers four common patterns and when to use each. ### Standalone Host The proxy runs on a dedicated machine (bare metal, EC2 instance, or VM) that sits between your workloads and the internet. Workloads on the same host or network point their DNS at the proxy, and iptables rules on the host prevent anything from bypassing it. ``` ┌──────────────────────────────────────────────────────────┐ │ Host │ │ │ │ ┌──────────┐ ┌──────────┐ │ │ │workload-A│ │workload-B│ │ │ │ │ │ │ │ │ │ dns: ────┼──┼──────────┼──► iron-proxy ──► internet │ │ │ proxy IP │ │ dns: │ :53 :80 :443 │ │ │ │ │ proxy IP │ │ │ └──────────┘ └──────────┘ │ │ │ │ iptables: DROP all outbound except from iron-proxy │ └──────────────────────────────────────────────────────────┘ ``` This is the pattern used in the [Amazon ECS guide](/deploy/ecs), where iron-proxy runs as a daemon service on each EC2 instance and workload containers share the host's Docker bridge network. #### Pros * **Strong circumvention resistance.** Host-level iptables rules prevent workloads from reaching the internet directly. Even if a workload hardcodes an IP address or uses its own DNS resolver, the traffic is dropped. * **Shared across workloads.** One proxy instance covers all containers or processes on the host. Configuration and CA certificates live in one place. * **Simple networking.** Workloads only need their DNS pointed at the proxy. No per-container network plumbing required. #### Cons * **Requires host access.** You need control over the host's network stack to set up iptables rules. This rules out managed container platforms like AWS Fargate or Google Cloud Run. * **Shared failure domain.** If iron-proxy goes down, all workloads on the host lose network access. You need to plan for restarts and health checks. * **IP assignment can be fragile.** On Docker bridge networks, iron-proxy's IP depends on container startup order. If a workload starts before the proxy, DNS resolution fails. Daemon services or init containers help here. #### When to Use Use this when you control the host and run multiple workloads that share a network. Container orchestrators with daemon scheduling (ECS daemon services, Kubernetes DaemonSets) are a natural fit. ### Network Proxy The proxy runs on a dedicated box in your network, separate from the machines running your workloads. Workload hosts use security groups, iptables, or firewall rules to ensure all outbound traffic routes through the proxy box. Nothing else is allowed to reach the internet directly. ``` ┌──────────┐ ┌──────────┐ │workload-A│ │workload-B│ │ │ │ │ │ dns: │ │ dns: │ │ proxy IP │ │ proxy IP │ └────┬─────┘ └────┬─────┘ │ │ ▼ ▼ ┌──────────────────────────┐ │ iron-proxy box │ │ 10.0.1.50 │ │ :53 :80 :443 │ └────────────┬─────────────┘ │ ▼ internet Security groups / firewall: workload hosts → 10.0.1.50:53,80,443 ALLOW workload hosts → 0.0.0.0/0 DENY ``` #### Pros * **Strongest circumvention resistance.** Enforcement happens outside the workload's trust boundary. A compromised workload cannot modify security group rules or firewall settings, even with root access on its own host. This makes it the hardest deployment model to bypass. * **No host or container access needed on workload machines.** You do not need to install anything on the workload hosts themselves. * **Centralized management.** One proxy instance (or a small cluster behind a load balancer) serves your entire fleet. Policy, CA certificates, and audit logs live in one place. * **Works with any compute platform.** VMs, containers, bare metal, managed services: if the platform supports security groups or outbound firewall rules, you can route traffic through the proxy box. #### Cons * **Depends on correct network configuration.** Security groups and firewall rules must be correctly configured and locked down. If a workload host can modify its own routing or security group rules, it can bypass the proxy. * **Single point of failure.** All workloads depend on the proxy box for network access. You need redundancy (multiple instances behind a load balancer, health checks, auto-scaling) to avoid downtime. * **Network latency.** Traffic takes an extra hop through the proxy box. For most workloads this is negligible, but latency-sensitive applications may notice. * **CA distribution.** Workload hosts still need to trust iron-proxy's CA certificate for TLS interception. You need a mechanism to distribute the CA (baked into images, pulled from a shared store, etc.). #### When to Use Use this when you want centralized egress control across many machines and your network layer supports outbound firewall rules. Cloud environments with security groups (AWS, GCP, Azure) are a natural fit. This is also a good option when you cannot modify the workload hosts but can control network routing. ### Sidecar A proxy container runs alongside each workload container. The two containers share a network namespace (e.g., a Kubernetes pod or a Docker Compose service with `network_mode`), so the workload connects to iron-proxy on `127.0.0.1`. ``` ┌─────────────────────────────────────────────┐ │ Pod / Compose service │ │ (shared network namespace) │ │ │ │ ┌──────────┐ ┌──────────────────┐ │ │ │ workload │ │ iron-proxy │ │ │ │ │───►│ :53 :80 :443 │──► internet │ │ dns: │ │ │ │ │ │ 127.0.0.1│ └──────────────────┘ │ │ └──────────┘ │ └─────────────────────────────────────────────┘ ``` #### Pros * **Works on managed platforms.** Sidecars run in the same pod or task as your workload, so you do not need host-level access. This works on Kubernetes, Docker Compose, and some managed container platforms. * **Per-workload isolation.** Each workload gets its own proxy instance with its own policy. A misconfiguration or crash only affects one workload. * **Predictable networking.** The proxy is always at `127.0.0.1`. No bridge IP guessing or startup ordering issues. #### Cons * **Weaker circumvention resistance without network policies.** In a shared network namespace, the workload and proxy share the same IP. You cannot use iptables `--uid-owner` rules inside a standard container unless it has `NET_ADMIN` capability. Without additional enforcement (e.g., Kubernetes NetworkPolicies, Cilium, or a CNI plugin), a workload can bypass the proxy by connecting to external IPs directly. * **More resource overhead.** Each workload runs its own iron-proxy instance. CPU and memory usage scales linearly with the number of workloads. * **Configuration duplication.** Policy and CA certificates must be distributed to every sidecar. This is manageable with ConfigMaps or shared volumes, but adds operational surface. :::info To make sidecar deployments circumvention-resistant on Kubernetes, pair iron-proxy with NetworkPolicies that restrict pod egress to only the sidecar's ports. This gives you DNS-level and network-level enforcement without requiring host access. ::: #### When to Use Use this when you cannot control the host (managed Kubernetes, Fargate-style platforms) or when you need per-workload policy isolation. Pair with network policies for stronger circumvention resistance. ### Embedded in a VM or Sandbox The proxy runs inside the same VM or sandbox as your workload. It binds to `127.0.0.1`, DNS is pointed at localhost, and iptables rules block all non-loopback egress from non-root processes. iron-proxy runs as root so its traffic is allowed through. ``` ┌──────────────────────────────────────────────┐ │ VM / Sandbox │ │ │ │ workload (user process) │ │ │ │ │ ▼ │ │ iron-proxy (root, 127.0.0.1) │ │ :53 :80 :443 │ │ │ │ │ ▼ │ │ iptables: REJECT non-root, non-loopback │ │ egress │ │ │ │ │ ▼ │ │ internet │ └──────────────────────────────────────────────┘ ``` This is the pattern used in the [bare metal guide](/deploy/bare-metal), the [Daytona guide](/deploy/daytona), the [Freestyle guide](/deploy/freestyle), and the [GitHub Actions guide](/deploy/github-actions). #### Pros * **Strong circumvention resistance.** Kernel-level iptables rules prevent non-root processes from reaching the network directly. However, this relies on the workload not being able to escalate to root: if sudo is available or the workload can exploit a privilege escalation, it bypasses the proxy entirely. * **Self-contained.** Everything lives inside one VM: proxy, policy, CA, iptables rules. No external dependencies or shared infrastructure. * **Good for ephemeral workloads.** Snapshot the VM with iron-proxy pre-installed and boot new instances in seconds. Each gets a fresh, isolated environment. #### Cons * **One proxy per VM.** Each VM runs its own iron-proxy instance. If you are running many concurrent workloads, this means many proxy instances. * **Requires kernel support.** The `--uid-owner` iptables match needs the `xt_owner` kernel module. Some lightweight VM platforms do not include it, which limits you to DNS-only interception. * **Privilege management matters.** The iptables rules allow traffic from root. If you forget to drop sudo access before running untrusted code, the workload can escalate to root and bypass the proxy entirely. #### When to Use Use this for ephemeral, isolated workloads like AI coding agents, user-submitted code execution, and security-sensitive CI jobs. For the strongest circumvention resistance, pair this with a [network proxy](#network-proxy) so that even a privilege escalation inside the VM cannot bypass egress control. ### Comparison | | Standalone Host | Network Proxy | Sidecar | VM / Sandbox | | ---------------------------- | ----------------------- | ------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------ | | **Circumvention resistance** | Strong (host iptables) | Strongest (enforcement outside workload trust boundary) | Weak without network policies | Strong (kernel iptables, but depends on privilege isolation) | | **Host access required** | Yes | No | No | No (but needs kernel module support) | | **Workloads per proxy** | Many | Many | One | One | | **Operational complexity** | Medium | Medium (plus redundancy) | Medium-High | Low per-instance, higher at scale | | **Best for** | Container orchestrators | Centralized fleet control | Managed platforms, per-workload policy | Ephemeral VMs, untrusted code | ## AWS Request Signing The `aws_auth` transform re-signs inbound AWS SigV4 requests with real credentials, so workloads can configure their AWS SDK with placeholders and point at the proxy. ```yaml - name: aws_auth config: access_key_id: {type: env, var: AWS_ACCESS_KEY_ID} secret_access_key: {type: env, var: AWS_SECRET_ACCESS_KEY} rules: - host: "*.amazonaws.com" ``` iron-proxy reads the region and service from the inbound SigV4 credential scope and re-signs with the real credentials. One config entry covers every AWS service the client talks to. Requires MITM mode. ### How It Works 1. **Inbound signature parsing.** The workload's AWS SDK signs the request with placeholder credentials. iron-proxy reads the credential scope (`ACCESSKEY/DATE/REGION/SERVICE/aws4_request`) from the `Authorization` header or, for pre-signed URLs, the `X-Amz-Credential` query parameter. 2. **Scope gating.** If `allowed_regions` or `allowed_services` is set, the inbound scope must match. Otherwise the request is rejected with HTTP 403 before any credential is resolved. 3. **Credential resolution.** `access_key_id` and `secret_access_key` each resolve through their own [secret source](/credential-proxying/static-secrets#secret-sources), or `credentials_provider` resolves them through the AWS SDK's [default credential chain](#workload-identity). 4. **Body hashing.** iron-proxy reads the body, computes its SHA-256, and uses that as the payload hash. With `unsigned_payload: true` the proxy skips the body read and uses the literal `UNSIGNED-PAYLOAD` sentinel that S3 and Bedrock streaming accept. 5. **Re-signing.** The proxy strips the placeholder signature headers (`Authorization`, `X-Amz-Date`, `X-Amz-Security-Token`, `X-Amz-Content-Sha256`) and re-signs the request using the AWS SDK v4 signer with the real credentials, the scope's region and service, and the current time. 6. **Header injection.** The proxy attaches the new `Authorization`, `X-Amz-Date`, and `X-Amz-Content-Sha256` headers. ### Configuring The Workload The workload's AWS SDK needs three things: placeholder credentials, the correct region, and the proxy as its endpoint. ```python import boto3 bedrock = boto3.client( "bedrock-runtime", region_name="us-east-1", endpoint_url="https://bedrock-runtime.us-east-1.amazonaws.com", aws_access_key_id="AKIAIOSFODNN7EXAMPLE", aws_secret_access_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", ) ``` The access key and secret are AWS's documented example values. They have the right shape (20-character access key starting with `AKIA`, 40-character secret) for the SDK to accept them and produce a SigV4 signature. The values themselves are meaningless: iron-proxy strips the placeholder signature and re-signs with the real credentials before the request leaves the proxy. Any syntactically valid pair works; using the AWS example values makes it obvious to the next reader that these aren't real. Route the workload's outbound traffic through iron-proxy (DNS interception, explicit proxy config, or a SOCKS tunnel). The SDK signs with the placeholder credentials; the proxy reads the region and service from that signature, re-signs with the real credentials, and forwards upstream. ### Workload Identity On EKS, ECS, or EC2, you don't have to give iron-proxy a long-lived access key. Set `credentials_provider` instead and the proxy resolves credentials through the AWS SDK's default chain: IAM Roles for Service Accounts (IRSA), EKS Pod Identity, and IMDSv2. ```yaml - name: aws_auth config: credentials_provider: type: workload_identity allowed_services: ["bedrock"] rules: - host: "*.amazonaws.com" ``` The proxy holds the rotating pod credentials. The workload still runs with the AWS example placeholder credentials, so real keys never reach the agent even though the underlying credential is short-lived. `credentials_provider` is mutually exclusive with `access_key_id` and `secret_access_key`: set one or the other, not both. The AWS SDK refreshes the credentials on its own schedule; iron-proxy picks up the rotation automatically. The provider discovers the region the same way the AWS SDK does (`AWS_REGION`, instance metadata, container metadata). Set `region` on the provider block to override that discovery. ```yaml credentials_provider: type: workload_identity region: us-east-1 ``` ### Scope Gating Without `allowed_regions` or `allowed_services`, the proxy will sign for any region and service the workload requests. That's convenient but broad. Limit the scope explicitly when you can: ```yaml - name: aws_auth config: access_key_id: {type: env, var: AWS_ACCESS_KEY_ID} secret_access_key: {type: env, var: AWS_SECRET_ACCESS_KEY} allowed_regions: ["us-east-1", "eu-west-1"] allowed_services: ["bedrock", "s3", "dynamodb"] rules: - host: "*.amazonaws.com" ``` A request with a credential scope outside the allowed sets is rejected with HTTP 403 and `rejected: region_not_allowed` or `rejected: service_not_allowed`. The proxy never reaches the real credentials in that case. ### Body Handling `aws_auth` has to know the request body to compute its SHA-256 (the payload hash AWS signs over). Two modes: * **Default.** iron-proxy reads the full body, hashes it, and replays it to the upstream. Bodies shorter than `Content-Length` are rejected with HTTP 413 (`body_truncated`). Chunked bodies are rejected with HTTP 400 (`chunked_body_not_allowed`) unless you opt in with `allow_chunked_body: true`. Raise [`proxy.max_request_body_bytes`](/reference/configuration#proxy) if your workload sends large bodies. * **Unsigned payload.** Set `unsigned_payload: true` to skip the body read entirely. The proxy sends the literal `UNSIGNED-PAYLOAD` sentinel as the payload hash. AWS accepts this for S3, Bedrock streaming, and other services that document support for it; check the service's signing reference before turning it on. If you use `allow_chunked_body: true`, the proxy logs a warning per signed chunked request and signs the buffered body as-is. There is no way to verify it matches what the client sent. ### Configuration Reference | Field | Type | Default | Description | | ---------------------- | ------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `access_key_id` | secret source | | Real AWS access key ID. Required unless `credentials_provider` is set. | | `secret_access_key` | secret source | | Real AWS secret access key. Required unless `credentials_provider` is set. | | `credentials_provider` | object | | Resolves credentials through the AWS SDK default chain (IRSA, EKS Pod Identity, IMDSv2). Mutually exclusive with `access_key_id`/`secret_access_key`. See [Workload Identity](#workload-identity). | | `allowed_regions` | string\[] | any region | Allowlist of AWS regions the entry will sign for. Empty allows any. | | `allowed_services` | string\[] | any service | Allowlist of AWS services the entry will sign for. Empty allows any. | | `unsigned_payload` | boolean | `false` | Sign with `UNSIGNED-PAYLOAD` instead of reading and hashing the body. | | `allow_chunked_body` | boolean | `false` | Sign chunked-encoding bodies without length verification. | | `rules` | object\[] | **required** | Destinations this transform applies to. Uses the [allowlist rule format](/reference/configuration#allowlistrules). At least one rule is required. | Set exactly one of `access_key_id`/`secret_access_key` or `credentials_provider`. Each static credential field is a discrete [secret source](/credential-proxying/static-secrets#secret-sources). `env`, `aws_sm`, `aws_ssm`, `1password_connect`, and `1password` all work. `credentials_provider` accepts: | Field | Type | Default | Description | | -------- | ------ | ------------ | ------------------------------------------------------------------------------------------- | | `type` | string | **required** | Currently only `workload_identity`. | | `region` | string | discovered | Override the region the SDK would otherwise pick up from `AWS_REGION` or instance metadata. | ### Failure Modes | Reason | Status | When | | -------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------- | | `missing_sigv4` | 400 | The inbound request has no SigV4 signature: no SigV4 `Authorization` header and no `X-Amz-Credential` query parameter. | | `region_not_allowed` | 403 | The inbound credential scope's region isn't in `allowed_regions`. | | `service_not_allowed` | 403 | The inbound credential scope's service isn't in `allowed_services`. | | `credential_unavailable` | 502 | A configured secret source returned an error when the proxy asked for its value. | | `body_missing` | 400 | The request declared `Content-Length > 0` but the body was empty or absent. | | `body_truncated` | 413 | The buffered body was shorter than `Content-Length`. Raise `proxy.max_request_body_bytes`. | | `chunked_body_not_allowed` | 400 | The body arrived chunked and `allow_chunked_body` is not set. | | `body_read_failed` | 400 | The proxy couldn't read the body off the inbound socket. | | `signing_failed` | 500 | The AWS SDK signer returned an error. | ### Audit Log A successful signing produces these annotations on the `aws_auth` trace entry: * `injected`: the headers that were set. Always includes `header:Authorization`, `header:X-Amz-Date`, and `header:X-Amz-Content-Sha256`. * `service`: the AWS service taken from the inbound credential scope. * `region`: the AWS region taken from the inbound credential scope. Rejections annotate `rejected` with one of the reasons above, plus contextual fields (`region`, `service`, `credential`, `content_length`, `buffered_length`, `error`) as appropriate. ### Limitations * **MITM mode only.** `sni-only` has no method, path, or body to sign. * **SigV4 only.** The inbound request must already be SigV4-signed. iron-proxy validates and rewrites the signature; it doesn't sign from scratch. * **Re-signs at the egress boundary.** The signing time is the time iron-proxy re-signs, not the time the workload first signed. Clock skew between the proxy and AWS still matters; clock skew between the workload and the proxy doesn't. * **One credential set per entry.** Use multiple `aws_auth` entries with non-overlapping `rules` to map different upstream credentials to different workloads or destinations. ### Related * [Static Secrets](/credential-proxying/static-secrets): inject a static API key when the upstream isn't AWS SigV4. * [HMAC Request Signing](/credential-proxying/hmac-sign): the generic signing transform for non-AWS schemes. * [Configuration reference](/reference/configuration#aws_auth): the canonical schema for the `aws_auth` transform. ## GCP Service Accounts The `gcp_auth` transform mints Google Cloud access tokens from a service-account keyfile and injects them as `Authorization: Bearer` headers on matching requests. ```yaml - name: gcp_auth config: keyfile: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:gcp-sa-key" scopes: - "https://www.googleapis.com/auth/cloud-platform" rules: - host: "*.googleapis.com" ``` iron-proxy holds the keyfile, signs the JWT assertion, exchanges it at Google's token endpoint, and attaches the bearer to outbound calls. The workload never sees the keyfile. Requires MITM mode. Under the hood, `gcp_auth` is an RFC 7523 JWT-bearer flow with Google's keyfile format and metadata-server stubbing. For non-Google JWT-bearer flows (DocuSign, Salesforce, Box, Zoom Server-to-Server), use [`oauth_token` with `grant: jwt_bearer`](/credential-proxying/oauth-token#jwt_bearer) instead. ### How It Works 1. **Credential resolution.** The service-account JSON loads either from disk (`keyfile_path`), from any [secret source](/credential-proxying/overview#secret-sources) (`keyfile`), or from the Google Cloud default credential chain (`credentials_provider`). Set exactly one. See [Workload Identity](#workload-identity) for the metadata-server path. 2. **Token minting.** iron-proxy signs a JWT with the service account's RSA private key, posts it to Google's OAuth2 token endpoint as a `urn:ietf:params:oauth:grant-type:jwt-bearer` assertion, and receives a short-lived access token in exchange. Minting goes through `golang.org/x/oauth2/google`, the same code path the official Google Cloud SDKs use. 3. **Caching and refresh.** Tokens are cached in memory until they near expiry, then refreshed in the background. Single-flight deduplication ensures concurrent requests share one mint. 4. **Header injection.** When a request matches the entry's `rules`, iron-proxy sets `Authorization` to the cached or freshly minted bearer. 5. **Endpoint stubbing.** iron-proxy intercepts two kinds of token requests and answers them with a synthetic `iron-proxy-stub-token` response: requests to Google's OAuth2 token endpoint (`oauth2.googleapis.com/token`) and requests to the GCE/GKE metadata server's service-account token paths (`metadata.google.internal` and `169.254.169.254` under `/computeMetadata/v1/instance/service-accounts/*/token`). This lets Google Cloud SDKs run their normal Application Default Credentials dance without a real keyfile and without actually being on GCP. iron-proxy mints the real token separately and swaps it onto the upstream API call. 6. **Failure handling.** On any error during keyfile load, JWT signing, or token exchange, the request is rejected with HTTP 403 and the audit log carries `rejected: token_unavailable`. ### Scopes OAuth2 scopes are baked into the minted token and must be declared up front. List every scope the workload's calls will need: ```yaml scopes: - "https://www.googleapis.com/auth/cloud-platform" - "https://www.googleapis.com/auth/bigquery.readonly" ``` `cloud-platform` is the broadest scope and covers most GCP APIs. Narrower scopes (`bigquery.readonly`, `devstorage.read_only`, and so on) limit what a captured token could do. One `gcp_auth` transform can serve multiple GCP APIs. Widen its `scopes` and `rules` to cover them. Use a second entry only when you want different service accounts or different scope sets for different hosts. ### Domain-Wide Delegation Set `subject` to impersonate a Google Workspace user. The minted token then acts as that user rather than as the service account. This is required for service-account access to user data in Gmail, Drive, Calendar, and the Admin SDK. ```yaml - name: gcp_auth config: keyfile: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:gcp-sa-key" subject: "automation@example.com" scopes: - "https://www.googleapis.com/auth/admin.directory.user.readonly" rules: - host: "admin.googleapis.com" ``` The service account must be authorized in the Workspace Admin Console for the scopes it impersonates, and `subject` must be the email of a real Workspace user. Without delegation set up on the Google side, the token mint succeeds but every API call returns 403. `subject` is incompatible with `credentials_provider`: metadata-server credentials can't perform the JWT-bearer assertion that domain-wide delegation requires. Setting both is a configuration error and the proxy refuses to start. ### Workload Identity On GKE, Cloud Run, or any environment with Application Default Credentials configured, you don't have to give iron-proxy a service-account JSON file. Set `credentials_provider` and the proxy resolves credentials through Google's default chain: GKE Workload Identity, `GOOGLE_APPLICATION_CREDENTIALS`, and Workload Identity Federation (for federated identities from AWS, Azure, or any OIDC provider). ```yaml - name: gcp_auth config: credentials_provider: type: workload_identity scopes: - "https://www.googleapis.com/auth/cloud-platform" rules: - host: "*.googleapis.com" ``` The proxy holds the rotating pod credentials. The workload talks to the proxy with the same `iron-proxy-stub-token` it would receive via metadata server stubbing, so real credentials never reach the agent even though the underlying identity is short-lived and federated. `credentials_provider` is mutually exclusive with `keyfile` and `keyfile_path`. Set exactly one. ### Examples #### Keyfile From Disk The simplest setup. Mount the service-account JSON file into the proxy container. ```yaml - name: gcp_auth config: keyfile_path: "/etc/iron-proxy/gcp-sa.json" scopes: - "https://www.googleapis.com/auth/cloud-platform" rules: - host: "*.googleapis.com" ``` #### Keyfile From AWS Secrets Manager Recommended for production. Keeps the keyfile out of disk images and CI logs. ```yaml - name: gcp_auth config: keyfile: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:gcp-sa-key" region: "us-east-1" ttl: 1h scopes: - "https://www.googleapis.com/auth/cloud-platform" rules: - host: "*.googleapis.com" ``` The `ttl` lets you rotate the keyfile in AWS without restarting the proxy. The same pattern works with `aws_ssm`, `1password_connect`, or any other [secret source](/credential-proxying/static-secrets#secret-sources). The keyfile JSON only has to land somewhere the proxy can read. #### Multiple Service Accounts One iron-proxy can serve several GCP service accounts. Add multiple `gcp_auth` entries with non-overlapping `rules`: ```yaml transforms: - name: gcp_auth config: keyfile: {type: env, var: GCP_SA_BIGQUERY_JSON} scopes: ["https://www.googleapis.com/auth/bigquery"] rules: - host: "bigquery.googleapis.com" - name: gcp_auth config: keyfile: {type: env, var: GCP_SA_STORAGE_JSON} scopes: ["https://www.googleapis.com/auth/devstorage.read_only"] rules: - host: "storage.googleapis.com" ``` The first entry with matching rules wins, so order entries from most specific to least specific. ### Metadata Server Stubbing Google Cloud SDKs running outside GCP often probe the metadata server (`169.254.169.254` or `metadata.google.internal`) to discover that they're on a GCE or GKE instance and fetch credentials from it. iron-proxy intercepts the service-account token endpoints on the metadata server and returns the stub token described above. SDKs that use Application Default Credentials work transparently: ```python # Inside the workload. No GCP credentials configured locally. from google.cloud import bigquery client = bigquery.Client() # Library probes the metadata server, gets the stub token, makes a BigQuery call # with Authorization: Bearer iron-proxy-stub-token. iron-proxy swaps the stub # for a real token before the request leaves the proxy. ``` This is the same stubbing pattern `oauth_token` uses for arbitrary OAuth2 token endpoints, specialized for Google's metadata service. Stubbing runs before rule evaluation, so SDKs can complete credential discovery even when your `rules` only target the API hosts. ### Audit Log A successful injection produces these annotations on the `gcp_auth` trace entry: * `service_account`: the `client_email` from the keyfile. * `injected`: `["header:Authorization"]`. A stubbed metadata or token endpoint request is annotated `stubbed: oauth2_token_endpoint` and rendered as the `stub` action. A mint failure annotates `error` and `rejected: token_unavailable`, and the request is rejected with HTTP 403. ### Limitations * **Service-account keyfiles and ADC only.** Static credential configs must be service-account JSON (`JWTConfigFromJSON`). For Workload Identity Federation and other `external_account` configurations, use [`credentials_provider`](#workload-identity), which resolves through the Google Cloud default chain. * **Domain-wide delegation requires a keyfile.** `subject` is incompatible with `credentials_provider`. Metadata-server credentials can't perform the JWT-bearer assertion DWD needs. * **MITM mode only.** `sni-only` can't rewrite headers. * **One service account per entry.** Use multiple `gcp_auth` entries with non-overlapping `rules` to serve more than one. ### Related * [OAuth2 Token Injection](/credential-proxying/oauth-token): the same JWT-bearer machinery for non-Google vendors (DocuSign, Salesforce, Box, Zoom). * [Static Secrets](/credential-proxying/static-secrets): supply the keyfile from AWS Secrets Manager, 1Password Connect, or any other backend. * [Configuration reference](/reference/configuration#gcp_auth): the canonical schema for the `gcp_auth` transform. ## HMAC Request Signing The `hmac_sign` transform signs outbound requests with HMAC and injects the signature into the headers the upstream expects. ```yaml - name: hmac_sign config: timestamp: format: unix_seconds signature: algorithm: sha256 key_encoding: base64 output_encoding: base64 message: "{{.Timestamp}}{{.Method}}{{.PathWithQuery}}{{.Body}}" credentials: secret: {type: env, var: API_SECRET} headers: - {name: "X-Signature", value: "{{.Signature}}"} - {name: "X-Timestamp", value: "{{.Timestamp}}"} rules: - host: "api.example.com" ``` iron-proxy holds the HMAC key, computes the signature over a message template you configure, and adds the result to a set of headers. The workload sends an unsigned request; the proxy attaches the signature on the way out. Requires MITM mode. ### How It Works For each matching request: 1. **Body buffering.** iron-proxy reads the request body in full. If it falls short of `Content-Length` or arrives chunked, the request is rejected before any signing happens. See [Body Integrity](#body-integrity). 2. **Credential resolution.** Each entry under `credentials` is its own [secret source](/credential-proxying/static-secrets#secret-sources). The entry named `secret` is required and holds the HMAC key. All other entries are user-named and available to header templates as `.Credentials.`. 3. **Timestamp.** iron-proxy generates a timestamp in the configured `timestamp.format` (`unix_seconds`, `unix_millis`, `unix_nanos`, `rfc3339`). The same value is passed to both the message template and the header templates as `.Timestamp`. 4. **Signature.** The proxy renders `signature.message` into a string, decodes the HMAC key according to `signature.key_encoding` (`raw`, `base64`, `hex`), HMACs the message with `signature.algorithm` (`sha256`, `sha512`, `sha1`), and encodes the result with `signature.output_encoding` (`base64`, `hex`). The encoded signature is available to header templates as `.Signature`. 5. **Header injection.** Each entry under `headers` is rendered as a Go template and set on the request in declaration order. Header names go on the wire with the exact casing written in config (HTTP/1.x only: HTTP/2 lowercases header names regardless). ### Template Variables #### `signature.message` The message template receives a struct with these fields: | Field | Description | | ---------------- | --------------------------------------------------------------------------- | | `.Timestamp` | The formatted timestamp string. | | `.Method` | HTTP method (`GET`, `POST`, etc.). | | `.Path` | URL path only, no query string. | | `.PathWithQuery` | `path?query` when a query string is present, otherwise the path on its own. | | `.Query` | Raw query string (no leading `?`). Empty when absent. | | `.Host` | Request host with any port stripped. | | `.Body` | The full request body as a string. Empty for bodyless requests. | Templates use Go `text/template` syntax with `missingkey=error`. Referencing an unknown field is a config-time parse error. #### `headers[].value` Each header value template receives: | Field | Description | | --------------------- | ---------------------------------------------------------------------- | | `.Timestamp` | The same timestamp used to build the signature. | | `.Signature` | The computed signature, encoded per `signature.output_encoding`. | | `.Credentials.` | Any credential resolved under `credentials`, keyed by its config name. | The `secret` credential (the HMAC key) is also available as `.Credentials.secret` if you need to send it in a header, though most signing schemes do not require this. ### Body Integrity A truncated body produces a signature the upstream rejects, and that's harder to debug than a clean proxy-side rejection. `hmac_sign` enforces two rules before computing the signature: * **Buffered length must equal `Content-Length`.** If the proxy's `proxy.max_request_body_bytes` truncated the body, the request is rejected with HTTP 413 and `rejected: body_truncated`. If you sign large bodies, raise `proxy.max_request_body_bytes` accordingly. * **Chunked bodies are rejected by default.** Without a `Content-Length`, the proxy can't prove the buffered body matches what the client sent. Rejected with HTTP 400 and `rejected: chunked_body_not_allowed`. Set `allow_chunked_body: true` to opt in. iron-proxy logs a warning per signed chunked request and signs the buffered body as-is. Other failure modes: | Reason | Status | When | | ------------------------- | ------ | ---------------------------------------------------------------------------------------- | | `credential_unavailable` | 502 | A credential's secret source returned an error (e.g., AWS Secrets Manager rate-limited). | | `key_decode_failed` | 500 | The HMAC key didn't decode under the configured `key_encoding`. | | `message_template_failed` | 500 | The `signature.message` template failed to execute against a real request. | | `header_template_failed` | 500 | One of the `headers[].value` templates failed to execute. | | `body_read_failed` | 400 | The proxy couldn't read the body off the inbound socket. | | `body_missing` | 400 | The request declared `Content-Length > 0` but the body was empty or absent. | ### Building A Signing Config Most HMAC signing schemes reduce to the same five choices. To configure `hmac_sign` for a new API: 1. **Timestamp format.** Check the API's signing docs. Most use Unix seconds or milliseconds. RFC 3339 is the third common option. 2. **Hash algorithm.** `sha256` is dominant. `sha512` and `sha1` are also supported. 3. **Key and signature encodings.** Keys usually arrive base64 or hex encoded. Signatures are usually emitted base64 or hex. The defaults match most APIs. 4. **String to sign.** Look up the API's "string to sign" or "prehash" definition and translate it into a Go template using `.Timestamp`, `.Method`, `.Path`, `.PathWithQuery`, `.Query`, `.Host`, `.Body`. Reference auxiliary credentials with `.Credentials.` if they appear in the prehash. 5. **Headers.** Anything the API wants alongside the signature (API key, passphrase, client ID, the signature, the timestamp) becomes a `credentials` entry plus a `headers` entry that interpolates it. If the API also wants a static header that isn't part of the signature or any HMAC credential, inject it with a separate [`secrets`](/credential-proxying/static-secrets) entry rather than packing it into `hmac_sign`. ### Examples #### Signature Over Timestamp, Method, Path, Body A common scheme: HMAC-SHA256 over `timestamp + method + path + body`. Key base64-encoded, signature base64-encoded, signature sent in a request header alongside the timestamp and an API key. ```yaml - name: hmac_sign config: timestamp: format: unix_seconds signature: algorithm: sha256 key_encoding: base64 output_encoding: base64 message: "{{.Timestamp}}{{.Method}}{{.PathWithQuery}}{{.Body}}" credentials: key: {type: env, var: API_KEY} secret: {type: env, var: API_SECRET} passphrase: {type: env, var: API_PASSPHRASE} headers: - {name: "X-ACCESS-KEY", value: "{{.Credentials.key}}"} - {name: "X-ACCESS-SIGN", value: "{{.Signature}}"} - {name: "X-ACCESS-TIMESTAMP", value: "{{.Timestamp}}"} - {name: "X-ACCESS-PASSPHRASE", value: "{{.Credentials.passphrase}}"} rules: - host: "api.example.com" ``` #### Credential Mixed Into The Prehash Some schemes mix the access key into the string to sign. Reference the credential from the message template: ```yaml - name: hmac_sign config: timestamp: format: unix_seconds signature: algorithm: sha256 key_encoding: raw output_encoding: hex message: "{{.Timestamp}}{{.Method}}{{.Path}}{{.Credentials.key}}{{.Body}}" credentials: key: {type: env, var: API_KEY} secret: {type: env, var: API_SECRET} headers: - {name: "X-Access-Key", value: "{{.Credentials.key}}"} - {name: "X-Signature", value: "{{.Signature}}"} - {name: "X-Timestamp", value: "{{.Timestamp}}"} rules: - host: "api.example.com" ``` ### Audit Log A successful signing produces: * `injected`: the headers that were set, e.g. `["header:X-ACCESS-KEY", "header:X-ACCESS-SIGN", ...]`. A rejection annotates `rejected` with one of the reasons from the [body integrity](#body-integrity) table, plus contextual fields (`content_length`, `buffered_length`, `credential`, `header`, `error`) as appropriate. ### Limitations * **MITM mode only.** `sni-only` has no method, path, or body to sign. * **No response signing.** `hmac_sign` only signs outbound requests. Response verification belongs in the workload or a downstream transform. * **No request mutation.** The buffered body is replayed unchanged to the upstream. If you need to rewrite the body before signing (canonicalize JSON, for example), do it in a separate transform earlier in the pipeline. ### Related * [AWS Request Signing](/credential-proxying/aws-auth): purpose-built signing for AWS SigV4 (use this for AWS, not `hmac_sign`). * [OAuth2 Token Injection](/credential-proxying/oauth-token): bearer-token auth when the upstream expects OAuth2 instead of HMAC. * [Static Secrets](/credential-proxying/static-secrets): inject static headers the API wants alongside the signature. * [Configuration reference](/reference/configuration#hmac_sign): the canonical schema for the `hmac_sign` transform. ## OAuth2 Token Injection The `oauth_token` transform mints short-lived OAuth2 access tokens and injects them as `Authorization: Bearer` headers on matching requests. ```yaml - name: oauth_token config: tokens: - grant: client_credentials client_id: {type: env, var: API_CLIENT_ID} client_secret: {type: env, var: API_CLIENT_SECRET} token_endpoint: "https://login.example.com/oauth2/token" rules: - host: "api.example.com" ``` iron-proxy holds the refresh token, client secret, or JWT signing key, performs the token exchange, and attaches the resulting bearer to outbound requests. The workload never holds a long-lived credential. Requires MITM mode. ### How It Works Each entry under `tokens` declares one OAuth2 flow: 1. **Credential resolution.** Each credential field (`refresh_token`, `client_id`, `private_key`, etc.) is its own [secret source](/credential-proxying/static-secrets#secret-sources). Sources can be any of the standard backends: `env`, `aws_sm`, `aws_ssm`, `1password_connect`, `1password`. Use `json_key` on any source to pull a field out of a JSON secret. 2. **Token exchange.** iron-proxy POSTs the resolved credentials to the entry's `token_endpoint`. Caching, refresh on expiry, and single-flight deduplication go through `golang.org/x/oauth2`, the same library the official SDKs use. 3. **Injection.** When a request matches the entry's `rules`, iron-proxy sets the configured header (default `Authorization` with prefix `Bearer `) to the cached or freshly minted bearer. 4. **Stubbing.** iron-proxy also intercepts requests to the `token_endpoint` itself and answers them with a synthetic `iron-proxy-stub-token` response. This lets a sandboxed SDK run its normal OAuth2 handshake against the proxy without holding any real credentials. The real token is minted separately and swapped onto the upstream call. 5. **Failure handling.** If minting fails (the credential store is down, the token endpoint rejects the request, the network breaks), iron-proxy closes the inbound request with HTTP 502 instead of forwarding it unauthenticated. The audit log carries `rejected: token_unavailable`. ### Grants `oauth_token` supports four RFC 6749 / RFC 7523 grant types. The grant determines which credential fields are required. Everything else is shared. | Grant | RFC | Required credentials | Optional | Other required | | -------------------- | ------------- | ----------------------------------- | ---------------- | -------------- | | `refresh_token` | RFC 6749 §6 | `refresh_token`, `client_id` | `client_secret` | | | `client_credentials` | RFC 6749 §4.4 | `client_id`, `client_secret` | | | | `password` | RFC 6749 §4.3 | `username`, `password`, `client_id` | `client_secret` | | | `jwt_bearer` | RFC 7523 | `issuer`, `subject`, `private_key` | `private_key_id` | `audience` | #### `refresh_token` The proxy holds a long-lived refresh token and trades it for a short-lived access token on each call or on expiry. :::warning iron-proxy never writes rotated refresh tokens back to the secret store. With providers that rotate (Google, Okta, Auth0), run a single instance per refresh token. See [Refresh token rotation](#refresh-token-rotation) below. ::: ```yaml - grant: refresh_token refresh_token: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:gsuite-oauth" json_key: "refresh_token" client_id: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:gsuite-oauth" json_key: "client_id" client_secret: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:gsuite-oauth" json_key: "client_secret" token_endpoint: "https://oauth2.googleapis.com/token" scopes: - "https://www.googleapis.com/auth/gmail.readonly" rules: - host: "gmail.googleapis.com" ``` Omit `client_secret` for public PKCE clients. `json_key` pulls each field out of one JSON secret in Secrets Manager. ##### Refresh Token Rotation Some providers issue a new refresh token on every exchange and invalidate the old one. Google with rotation enabled, Auth0, and Okta with one-time-use refresh tokens all behave this way. iron-proxy keeps the rotated token in memory for the lifetime of the process and does not write it back to the secret source. In practice, the flow works until the secret source is re-read with the original token. The next exchange after that fails with `invalid_grant`, which iron-proxy classifies as unrecoverable, and a human has to seed a fresh refresh token in the secret store. Two implications: * Run a single iron-proxy instance per refresh token. A second instance reading the same secret holds the original value and will fail on its next exchange. For high availability, prefer `client_credentials` or `jwt_bearer`, which carry no per-call rotation state. * Avoid short `ttl` values on the refresh-token secret source. A re-read overwrites the in-memory rotated token with the stale store value. #### `client_credentials` Machine-to-machine flow. Use it when the API issues a client ID and secret for service auth and the workload doesn't have a user identity of its own. ```yaml - grant: client_credentials client_id: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123:secret:oauth" json_key: "client_id" client_secret: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123:secret:oauth" json_key: "client_secret" token_endpoint: "https://login.example.com/oauth2/token" rules: - host: "api.example.com" ``` `json_key` keeps both fields in one Secrets Manager entry. #### `password` The resource-owner password grant. Used by vendors that issue a username and password for a service account (not for a real human) and trade them for bearers. ```yaml - grant: password username: {type: env, var: API_USERNAME} password: {type: env, var: API_PASSWORD} client_id: {type: env, var: API_CLIENT_ID} client_secret: {type: env, var: API_CLIENT_SECRET} token_endpoint: "https://api.example.com/token" rules: - host: "api.example.com" ``` #### `jwt_bearer` RFC 7523. The proxy signs a JWT with an RSA private key, posts it as an assertion, and receives a bearer in exchange. This is the right grant for DocuSign, Salesforce, Box, Zoom Server-to-Server, Adobe Sign, and most other vendors that authenticate machine clients with a signed assertion. ```yaml - grant: jwt_bearer issuer: {type: env, var: DOCUSIGN_INTEGRATION_KEY} subject: {type: env, var: DOCUSIGN_USER_GUID} private_key: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:docusign-private-key" private_key_id: {type: env, var: DOCUSIGN_KEY_ID} audience: "account.docusign.com" token_endpoint: "https://account.docusign.com/oauth/token" scopes: ["signature", "impersonation"] rules: - host: "*.docusign.net" ``` `private_key` must resolve to a PEM-encoded RSA private key. `private_key_id`, when set, is emitted as the JWT `kid` header. `audience` is the JWT `aud` claim and is required for this grant. For Google service-account JWT-bearer auth, use the [`gcp_auth`](/credential-proxying/gcp-auth) transform instead. It wraps the same RFC 7523 flow around Google's keyfile format and also stubs the GCE/GKE metadata server. ### Token Endpoint Stubbing iron-proxy intercepts requests to the configured `token_endpoint` and answers them with a synthetic response: ```json {"access_token":"iron-proxy-stub-token","expires_in":3600,"token_type":"Bearer"} ``` The stub token is meaningless. When the client uses it on a subsequent API call (`Authorization: Bearer iron-proxy-stub-token`), iron-proxy matches the request against the entry's `rules` and swaps the stub for the real bearer. This lets sandboxed SDKs run their normal OAuth2 dance without any real credentials. The workload only needs a plausible-looking placeholder. The real values stay on the proxy. ### Token Endpoint Headers Some vendors want an API key on the token endpoint itself, alongside the standard form-body client auth. Use `token_endpoint_headers` to add headers to the token POST. ```yaml - grant: password username: {type: env, var: API_USERNAME} password: {type: env, var: API_PASSWORD} client_id: {type: env, var: API_CLIENT_ID} token_endpoint: "https://api.example.com/token" token_endpoint_headers: x-api-key: {type: env, var: API_KEY} rules: - host: "api.example.com" ``` Each value is its own secret source. Header casing is preserved on the wire (HTTP/1.x only). ### Vendor Recipes The grant, audience, and token endpoint are the only per-vendor differences. Credential field shapes are identical across vendors using the same grant. #### DocuSign ```yaml - grant: jwt_bearer issuer: {type: env, var: DOCUSIGN_INTEGRATION_KEY} subject: {type: env, var: DOCUSIGN_USER_GUID} private_key: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:docusign-private-key" audience: "account.docusign.com" # account-d.docusign.com for the dev sandbox token_endpoint: "https://account.docusign.com/oauth/token" scopes: ["signature", "impersonation"] rules: - host: "*.docusign.net" ``` #### Salesforce ```yaml - grant: jwt_bearer issuer: {type: env, var: SALESFORCE_CONSUMER_KEY} subject: {type: env, var: SALESFORCE_USERNAME} private_key: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:salesforce-private-key" audience: "https://login.salesforce.com" token_endpoint: "https://login.salesforce.com/services/oauth2/token" rules: - host: "*.my.salesforce.com" - host: "*.salesforce.com" ``` #### Box ```yaml - grant: jwt_bearer issuer: {type: env, var: BOX_CLIENT_ID} subject: {type: env, var: BOX_ENTERPRISE_ID} private_key: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:box-private-key" audience: "https://api.box.com/oauth2/token" token_endpoint: "https://api.box.com/oauth2/token" rules: - host: "api.box.com" - host: "upload.box.com" ``` #### Zoom Server-to-Server ```yaml - grant: jwt_bearer issuer: {type: env, var: ZOOM_ACCOUNT_ID} subject: {type: env, var: ZOOM_CLIENT_ID} private_key: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:zoom-private-key" audience: "zoom.us" token_endpoint: "https://zoom.us/oauth/token" rules: - host: "api.zoom.us" ``` #### Gmail (User OAuth) A long-lived Google refresh token issued through the user OAuth consent flow, traded on each call. ```yaml - grant: refresh_token refresh_token: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:gsuite-oauth" json_key: "refresh_token" client_id: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:gsuite-oauth" json_key: "client_id" client_secret: type: aws_sm secret_id: "arn:aws:secretsmanager:us-east-1:123456789:secret:gsuite-oauth" json_key: "client_secret" token_endpoint: "https://oauth2.googleapis.com/token" scopes: - "https://www.googleapis.com/auth/gmail.readonly" rules: - host: "gmail.googleapis.com" ``` #### AlphaSense AlphaSense uses a password grant for the bearer plus a static API key and client ID on every GraphQL call. `oauth_token` mints the bearer; a sibling `secrets` transform injects the two static headers. ```yaml - name: oauth_token config: tokens: - grant: password username: {type: env, var: ALPHASENSE_USERNAME} password: {type: env, var: ALPHASENSE_PASSWORD} client_id: {type: env, var: ALPHASENSE_CLIENT_ID} client_secret: {type: env, var: ALPHASENSE_CLIENT_SECRET} token_endpoint: "https://api.alpha-sense.com/token" token_endpoint_headers: x-api-key: {type: env, var: ALPHASENSE_API_KEY} rules: - host: "api.alpha-sense.com" paths: ["/gql", "/gql/*"] - name: secrets config: secrets: - source: {type: env, var: ALPHASENSE_API_KEY} inject: {header: "x-api-key"} rules: - host: "api.alpha-sense.com" paths: ["/gql", "/gql/*"] - source: {type: env, var: ALPHASENSE_CLIENT_ID} inject: {header: "clientId"} rules: - host: "api.alpha-sense.com" paths: ["/gql", "/gql/*"] ``` ### Credential Rotation When a secret source has a non-zero `ttl`, iron-proxy re-reads the credential on that interval and rebuilds the underlying token source whenever the value changes. A credential rotated in the backend store gets picked up on the next refresh without a restart. iron-proxy doesn't retain the plaintext credential between mints. ### Audit Log A successful injection produces these annotations on the `oauth_token` trace entry: * `grant`: the grant type (`refresh_token`, `client_credentials`, `password`, `jwt_bearer`). * `injected`: the headers that were set, e.g. `["header:Authorization"]`. A stubbed token endpoint request is annotated `stubbed: oauth2_token_endpoint` and rendered as the `stub` action. A mint failure annotates `grant`, `error`, and `rejected: token_unavailable`, and the request is rejected with HTTP 502. ### Limitations * **MITM mode only.** `sni-only` can't rewrite headers. * **First match wins.** When two `tokens` entries match the same request, the first in config order is used. Order entries from most specific to least specific. * **One header per entry.** An entry injects exactly one header. If the vendor also requires a static header (an API key, a client ID), inject it with a separate [`secrets`](/credential-proxying/static-secrets) entry. See the AlphaSense recipe above. ### Related * [GCP Service Accounts](/credential-proxying/gcp-auth): the same JWT-bearer flow wrapped around Google's keyfile format, with metadata-server stubbing. * [Static Secrets](/credential-proxying/static-secrets): inject the static headers some vendors require alongside an OAuth2 bearer. * [Configuration reference](/reference/configuration#oauth_token): the canonical schema for the `oauth_token` transform. ## Credential Proxying iron-proxy holds the credentials your workloads use to call upstream APIs, so the workloads never see the real values. Containers, CI jobs, and agents send unauthenticated requests (or requests carrying a placeholder token). The proxy attaches the real credential as the request leaves your network. If a workload is compromised, there is no real credential to steal. The right mechanism depends on what the upstream API expects. ### Picking A Strategy | Upstream expects | Use | What the proxy holds | | ------------------------------------------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------- | | A static API key, bearer token, or basic-auth password | [Static Secrets](/credential-proxying/static-secrets) | The literal credential value. | | An OAuth2 bearer minted from longer-lived credentials | [OAuth2 Token Injection](/credential-proxying/oauth-token) | A refresh token, client credentials, username/password, or RFC 7523 signing key. | | An HMAC signature over the outbound request | [HMAC Request Signing](/credential-proxying/hmac-sign) | The HMAC key and any auxiliary credentials the scheme requires. | | An AWS SigV4 signature | [AWS Request Signing](/credential-proxying/aws-auth) | An AWS access key and secret, or AWS workload identity credentials. | | A Google service-account access token | [GCP Service Accounts](/credential-proxying/gcp-auth) | A service-account JSON keyfile, or GCP workload identity credentials. | The five are independent. One iron-proxy can use any combination, scoped to different hosts. ### Common Concepts Every credential proxying mechanism shares the same scaffolding. #### Secret Sources You never put a credential value directly in the YAML config. Each credential field is a "secret source" object: a backend type plus a lookup key. iron-proxy reads the value at startup and caches it in memory. Five backends are supported: | Type | Reads from | | ------------------- | -------------------------------------------------------------- | | `env` | An environment variable on the iron-proxy process. | | `aws_sm` | AWS Secrets Manager. | | `aws_ssm` | AWS Systems Manager Parameter Store. | | `1password_connect` | A 1Password Connect server you run. Recommended for 1Password. | | `1password` | 1Password's hosted SDK. Rate-limited per account. | Every source supports two optional fields: * `json_key`: when set, the value is parsed as JSON and the named field is extracted. Use this to pack a `client_id` and `client_secret` into one Secrets Manager entry. * `ttl`: when non-zero, iron-proxy re-reads the value on that interval. Rotate a credential in the backing store and the proxy picks it up on the next refresh, no restart required. See [Static Secrets / Secret Sources](/credential-proxying/static-secrets#secret-sources) for the full field reference. #### Host Rules Every mechanism scopes itself with a `rules` list matching outbound requests by host, method, and path. Rules use the same shape as the [allowlist](/reference/configuration#allowlistrules). Scope credentials to the narrowest set of destinations that work. A credential that applies to every host leaks the moment a workload makes an unrelated request. #### MITM Mode All four mechanisms rewrite request headers, so iron-proxy has to terminate TLS. Use `tls.mode: mitm` (the default) and make sure workloads trust the proxy's CA. `sni-only` mode passes TLS through unmodified and cannot do credential proxying. #### Fail-Closed Behavior When a credential can't be resolved or a token can't be minted, iron-proxy closes the request rather than forwarding it unauthenticated: * `secrets` rejects with HTTP 403 if `require: true` and the workload didn't send the proxy token. * `oauth_token` rejects with HTTP 502 if token minting fails. * `hmac_sign` rejects with HTTP 400, 413, or 502 depending on the failure. * `aws_auth` rejects with HTTP 400, 403, 413, 500, or 502 depending on the failure. * `gcp_auth` rejects with HTTP 403 if token minting fails. The audit log carries the reason on every rejection. #### Pipeline Ordering Transforms run top-to-bottom in the order they appear under `transforms:`. Two rules matter most: * Put credential injection **after** any policy transform (`judge`, `mcp`, `body_capture`) that should see proxy tokens, not real credentials. * Put [`header_allowlist`](/reference/configuration#header_allowlist) **after** credential injection so injected headers survive the allowlist. When two credential transforms target the same host, the first one in config order wins. ### Related Pages * [Static Secrets](/credential-proxying/static-secrets): inject and replace patterns for API keys and tokens. * [OAuth2 Token Injection](/credential-proxying/oauth-token): the four OAuth2 grants, token endpoint stubbing, vendor recipes. * [HMAC Request Signing](/credential-proxying/hmac-sign): template variables, body integrity, signing scheme examples. * [AWS Request Signing](/credential-proxying/aws-auth): re-sign AWS SDK requests with real credentials. * [GCP Service Accounts](/credential-proxying/gcp-auth): service-account JWT-bearer flow with metadata-server stubbing. ## Static Secrets The `secrets` transform applies a fixed credential (API key, bearer token, basic-auth password) to outbound requests so the workload never holds the real value. ```yaml transforms: - name: secrets config: secrets: - source: {type: env, var: OPENAI_API_KEY} inject: header: "Authorization" formatter: "Bearer {{ .Value }}" rules: - host: "api.openai.com" methods: ["POST"] paths: ["/v1/*"] ``` This adds `Authorization: Bearer $OPENAI_API_KEY` to every POST to `api.openai.com/v1/*`. The workload sends a plain request; iron-proxy reads `OPENAI_API_KEY` from its environment and attaches the header on the way out. Use `secrets` when the upstream accepts a static credential. For other patterns, see [OAuth2 Token Injection](/credential-proxying/oauth-token), [HMAC Request Signing](/credential-proxying/hmac-sign), [AWS Request Signing](/credential-proxying/aws-auth), or [GCP Service Accounts](/credential-proxying/gcp-auth). ### How It Works Each secret entry has three parts: * **Source.** Where iron-proxy reads the credential value from. An environment variable, AWS Secrets Manager, 1Password Connect, and so on. * **Mode.** How the credential gets applied. *Inject* adds it unconditionally. *Replace* swaps a placeholder token in the workload's request for the real value. * **Rules.** Which destinations the secret applies to. Same host/method/path matching as [allowlist rules](/reference/configuration#allowlistrules). When a request matches a secret's rules, iron-proxy applies the credential before forwarding upstream. ### Inject Mode iron-proxy sets a header or query parameter on every matching request. The workload doesn't carry a credential at all: it sends a plain request and the proxy adds the authentication on the way out. The opening example above is in inject mode. #### Inject Options You can inject into either a header or a query parameter: * **`header`**: sets the named header. The header name is sent upstream with the exact casing written here (HTTP/1.x only: HTTP/2 lowercases header names regardless). Use `formatter` to control the value format. The `formatter` field is a Go template that receives `.Value` (the resolved secret) and a `base64` helper. * **`query_param`**: appends the named query parameter with the secret as its value. No formatter is needed. ```yaml # Inject as a query parameter inject: query_param: "api_key" ``` The `base64` helper is useful for services that use HTTP Basic authentication. For example, GitHub's API accepts Basic auth where the password is a personal access token. The `base64` helper concatenates its arguments and base64-encodes the result: ```yaml # GitHub Basic auth: base64("x-access-token:") inject: header: "Authorization" formatter: 'Basic {{ base64 "x-access-token:" .Value }}' ``` This produces a header like `Authorization: Basic eC1hY2Nlc3MtdG9rZW46Z2hwX2FiYzEyMw==`, which GitHub decodes and authenticates as a token-based login. #### When to Use Inject Mode Inject mode is the stronger isolation model. Workloads have no knowledge of credentials at all: no environment variables, no tokens, no placeholders. Reach for it when: * You control the workload's code and it doesn't need to reference the credential directly. * You want to rule out credential leakage from the workload's process environment. * The upstream API takes credentials in a single header or query parameter. ### Replace Mode The workload sends a proxy token: an opaque placeholder that means nothing outside iron-proxy. The proxy scans outbound requests for this token and swaps it for the real credential. ```yaml transforms: - name: secrets config: secrets: - source: type: aws_sm secret_id: "arn:aws:secretsmanager:us-west-1:123456789:secret:anthropic-key" region: "us-west-1" ttl: 5m replace: proxy_value: "pk-proxy-anthropic-xyz" match_headers: ["x-api-key"] rules: - host: "api.anthropic.com" ``` The workload sends `x-api-key: pk-proxy-anthropic-xyz`. iron-proxy swaps the placeholder for the real key from AWS Secrets Manager before the request reaches Anthropic. #### Replace Options * **`proxy_value`** (required): the placeholder token the workload uses. Choose something clearly identifiable (e.g., `pk-proxy--`). * **`match_headers`**: list of header names to scan for the token. An empty list scans all headers. Each entry is either a literal header name (case-insensitive) or a `/regex/` pattern. Literal and regex entries can be mixed in the same list. Headers are forwarded upstream with the exact casing written here (HTTP/1.x only: HTTP/2 lowercases header names regardless). * **`match_body`**: when `true`, the proxy also scans the request body for the token. * **`match_path`**: when `true`, the proxy scans `req.URL.Path` for the token. Off by default because URL paths often land in access logs on either side of the proxy. * **`match_query`**: when `true`, the proxy scans the URL query string for the token. Off by default for the same reason as `match_path`: query strings frequently appear in access logs. #### Path-Embedded Tokens Some upstreams put the credential in the URL path. Telegram is the canonical example: clients call `/bot/sendMessage`. Set `match_path: true` to swap a placeholder token in the path: ```yaml - source: type: env var: TELEGRAM_BOT_TOKEN replace: proxy_value: "proxy-tg-token-123" match_headers: [] match_path: true require: true rules: - host: "api.telegram.org" ``` Inject mode doesn't extend to paths. There's no anchor for the proxy to insert at, so the secret would either duplicate or overwrite the client's path. #### When to Use Replace Mode Reach for replace mode when: * You can't modify the workload's code, but you can set its environment variables or configuration. * The workload or its libraries expect a credential in the environment and include it in requests automatically. `git` reading `GITHUB_TOKEN` is the textbook case. * You want a stable proxy token that survives secret rotation. The real value changes; the proxy token stays the same. ### The `require` Flag Replace mode supports a `require` flag. When `true`, iron-proxy rejects matching requests with HTTP 403 if the proxy token is missing from every scanned location. This stops workloads from bypassing the proxy with their own credentials. ```yaml - source: type: env var: ANTHROPIC_API_KEY replace: proxy_value: "pk-proxy-anthropic" match_headers: ["x-api-key"] require: true rules: - host: "api.anthropic.com" ``` ### Secret Sources iron-proxy reads secret values from one of five backends: environment variables, AWS Secrets Manager, AWS Systems Manager Parameter Store, 1Password Connect, and 1Password. Each source is an object on the `source` field, discriminated by `type`. For 1Password deployments, prefer `1password_connect` over `1password`. The hosted 1Password SDK is rate-limited per account and can stall request handling under load. 1Password Connect runs in your own infrastructure and isn't subject to those limits. Every source supports an optional `json_key` field. When set, iron-proxy parses the value as JSON and extracts the named field. Use this to pack multiple credentials into a single JSON secret. #### Environment Variable (`env`) Reads the secret from an environment variable on the iron-proxy process. The simplest option: set the variable when starting iron-proxy and the proxy reads it at startup. ```yaml source: type: env var: MY_API_KEY ``` The value is read once at startup and held in memory. Restart iron-proxy with a new value to rotate. #### AWS Secrets Manager (`aws_sm`) Reads the secret from AWS Secrets Manager. The value is cached locally and refreshed on a TTL, so you can rotate secrets in Secrets Manager without restarting iron-proxy. ```yaml source: type: aws_sm secret_id: "arn:aws:secretsmanager:us-west-1:123456789:secret:my-key" region: "us-west-1" json_key: "api_key" ttl: 15m ``` | Field | Type | Default | Description | | ----------- | -------- | --------------- | ----------------------------------------------------------------- | | `secret_id` | string | required | Secret ARN or name in AWS Secrets Manager. | | `region` | string | AWS SDK default | AWS region where the secret is stored. | | `json_key` | string | | When set, parse the fetched value as JSON and extract this field. | | `ttl` | duration | `0` | Re-fetch interval. Set to `0` to read the value once at startup. | #### AWS Systems Manager Parameter Store (`aws_ssm`) Reads the secret from AWS Systems Manager Parameter Store. A lower-cost alternative to Secrets Manager. A good fit for teams already storing application configuration there. ```yaml source: type: aws_ssm name: "/myapp/api-key" region: "us-east-1" with_decryption: true json_key: "api_key" ttl: 15m ``` | Field | Type | Default | Description | | ----------------- | -------- | --------------- | -------------------------------------------------------------------------- | | `name` | string | required | Parameter name or ARN. | | `region` | string | AWS SDK default | AWS region where the parameter is stored. | | `with_decryption` | boolean | `true` | Decrypt `SecureString` parameters. Leave enabled for encrypted parameters. | | `json_key` | string | | When set, parse the fetched value as JSON and extract this field. | | `ttl` | duration | `0` | Re-fetch interval. Set to `0` to read the value once at startup. | iron-proxy authenticates to both `aws_sm` and `aws_ssm` through the standard AWS credential chain: environment variables, instance profile, ECS task role, and so on. No AWS configuration goes in the iron-proxy config file. The resolved identity must be authorized to read each referenced secret. See [Required AWS Permissions](#required-aws-permissions) below. #### 1Password (`1password`) Reads the secret from 1Password using a service account token. Values are cached locally and refreshed on a TTL. Prefer `1password_connect` (below) where possible. The hosted SDK is rate-limited per account and can become a bottleneck under load. ```yaml source: type: 1password secret_ref: "op://Engineering/OpenAI/credential" token_env: OP_SERVICE_ACCOUNT_TOKEN ttl: 15m ``` | Field | Type | Default | Description | | ------------ | -------- | -------------------------- | ----------------------------------------------------------------------- | | `secret_ref` | string | required | 1Password reference using the `op://vault/item/[section/]field` syntax. | | `token_env` | string | `OP_SERVICE_ACCOUNT_TOKEN` | Environment variable holding the 1Password service account token. | | `ttl` | duration | `0` | Re-fetch interval. Set to `0` to read the value once at startup. | The 1Password SDK loads a Wasm module on each client construction. iron-proxy reuses one client per `token_env` value across all entries that reference it. #### 1Password Connect (`1password_connect`) Reads the secret from a 1Password Connect server you run. The recommended 1Password integration: Connect runs locally and isn't subject to the per-account rate limits that throttle the hosted SDK. Values are cached locally and refreshed on a TTL. ```yaml source: type: 1password_connect secret_ref: "op://Engineering/OpenAI/credential" host_env: OP_CONNECT_HOST token_env: OP_CONNECT_TOKEN ttl: 15m ``` | Field | Type | Default | Description | | ------------ | -------- | ------------------ | ----------------------------------------------------------------------- | | `secret_ref` | string | required | 1Password reference using the `op://vault/item/[section/]field` syntax. | | `host_env` | string | `OP_CONNECT_HOST` | Environment variable holding the Connect server URL. | | `token_env` | string | `OP_CONNECT_TOKEN` | Environment variable holding the Connect API token. | | `ttl` | duration | `0` | Re-fetch interval. Set to `0` to read the value once at startup. | ### Scoping Secrets With Rules Every secret entry supports a `rules` array that restricts which requests the secret applies to. Rules use the same format as [allowlist rules](/reference/configuration#allowlistrules): host globs, methods, path patterns. ```yaml rules: - host: "api.openai.com" methods: ["POST"] paths: ["/v1/*"] ``` If you omit rules, the secret applies to every request through the proxy. Always scope secrets to the narrowest set of destinations that work, so a misconfiguration leaks as little as possible. ### Combining Multiple Secrets One `secrets` transform can hold many entries. Each one is evaluated independently. A request can match zero, one, or several. ```yaml transforms: - name: secrets config: secrets: - source: type: env var: OPENAI_API_KEY inject: header: "Authorization" formatter: "Bearer {{ .Value }}" rules: - host: "api.openai.com" - source: type: aws_sm secret_id: "arn:aws:secretsmanager:us-west-1:123456789:secret:anthropic-key" region: "us-west-1" ttl: 15m replace: proxy_value: "pk-proxy-anthropic" match_headers: ["x-api-key"] rules: - host: "api.anthropic.com" ``` You can mix inject and replace mode secrets in the same transform. ### Required AWS Permissions The `aws_sm` and `aws_ssm` sources call AWS at runtime, so the proxy host needs an AWS identity authorized to read each referenced secret. Without that identity, the proxy starts but every fetch fails. Configure permissions on the AWS side, not in the iron-proxy config. For Secrets Manager (`aws_sm`), attach a policy that grants `secretsmanager:GetSecretValue` (and `kms:Decrypt` if the secret is encrypted with a customer-managed KMS key) on each secret ARN the proxy will read: ```json { "Effect": "Allow", "Action": ["secretsmanager:GetSecretValue"], "Resource": [ "arn:aws:secretsmanager:us-west-1:123456789:secret:openai-key-*", "arn:aws:secretsmanager:us-west-1:123456789:secret:anthropic-key-*" ] } ``` For Parameter Store (`aws_ssm`), grant `ssm:GetParameter` on each parameter, plus `kms:Decrypt` when reading `SecureString` parameters with a customer-managed key: ```json { "Effect": "Allow", "Action": ["ssm:GetParameter"], "Resource": ["arn:aws:ssm:us-east-1:123456789:parameter/myapp/*"] } ``` Attach the policy to whatever identity the proxy host runs under: an EC2 instance profile, an ECS task role, an EKS IRSA service account, or an IAM user whose access keys are exported in the environment. Scope the resource ARNs to exactly the secrets the proxy needs. A fleet-wide `Resource: "*"` defeats the point of running a proxy in front of credentials. When the same secret is served by many proxies, give each proxy host its own role rather than sharing one. Per-host roles let you revoke a single compromised host without rotating every secret it could reach, and CloudTrail attributes the read to the right caller for audit. ### Related * [OAuth2 Token Injection](/credential-proxying/oauth-token): mint bearers from refresh tokens, client credentials, or JWT assertions. * [HMAC Request Signing](/credential-proxying/hmac-sign): sign requests with HMAC instead of attaching a static credential. * [Secret Policies API](/control-plane/api/secret-policies): manage `secrets` entries through the control plane instead of YAML. * [Configuration reference](/reference/configuration#secrets): the canonical schema for the `secrets` transform. ## Enrollment A new iron-proxy joins the control plane by running a one-time `init` command with an enrollment token. The proxy registers itself, picks up the tags configured on the token, pulls its initial configuration, and begins reporting audit events. After enrollment, the proxy stores a long-lived credential on disk and does not need the enrollment token again. ### Enroll a Proxy :::steps #### Create an Enrollment Token In the control plane UI, open the **Proxies** tab and click **Add Proxy**. The Add Proxy dialog, showing Tags, Key Expiry, and Max Uses fields Fill in: * **Tags.** Labels that will be assigned to any proxy registered with this token. Type a tag and press comma or enter to add it. * **Key Expiry.** How long the token can be redeemed before it is no longer valid. * **Max Uses.** How many proxies this token can register. `0` means unlimited. Click **Add Proxy**. The control plane displays the enrollment token once. Success message showing the enrollment token with its tags and expiry Copy the token immediately. It will not be shown again. #### Run `init` on the Host On the machine where iron-proxy will run, execute: ```bash sudo ./iron-proxy init \ -enrollment-token 1a46ef8a2f34650f6b3fba5f20990dde0910060d9faab3dfba9ab168cf4f3f43 ``` The `init` subcommand exchanges the enrollment token for a long-lived proxy credential, registers the proxy with the control plane along with the tags configured on the token, pulls the initial policy, installs the iron-proxy systemd services, and starts the `iron-proxy` service. When it returns, the proxy is already running and connected to the control plane. `sudo` is required because the state directory, iptables rules, and systemd units are all system-wide. #### Verify It Registered Back in the **Proxies** tab, the new proxy appears in the list with its tags and `Active` status. The Proxies list showing the newly enrolled proxy with its tags and status ::: ### Customizing the Generated Config `init` writes a config file to the proxy's state directory. It is preconfigured to connect to the control plane and apply the policy you just pulled, and will work as-is for most deployments. Anything in the standard [iron-proxy configuration](/reference/configuration) can be edited directly in the file: listen ports, upstream resolvers, log format, extra transforms. After editing, restart the service to pick up the changes: ```bash sudo systemctl restart iron-proxy ``` Control-plane-managed fields (policy, secret mappings, tags) are refreshed from the control plane on every reconnect, so local edits to those fields will be overwritten. Edit them in the control plane UI instead. ### Tags Each proxy carries a set of colon-separated labels that identify it and its workload. Tags can be bare strings like `dev` or namespaced like `env:prod`. Every audit event is indexed by the proxy's tags, so fleet-wide queries like "show every request from `env:prod` `service:checkout`" are cheap. Tags are configured on the enrollment token in the control plane UI. Every proxy registered with the token inherits that tag set. Changing a proxy's tags after enrollment is not yet exposed in the UI. For now, re-enroll the proxy with a fresh enrollment token to assign a new tag set. ### Enrollment Tokens Each enrollment is authorized by a short-lived credential that exists only for the handshake. Once the proxy has its long-lived credential, the token is discarded. `Max Uses` controls how many proxies a single token can register: * **1** is the tightest setting: the token can enroll exactly one proxy, then is dead. Prefer this for provisioning flows that hand out a token per host. * **A small integer** fits short-lived autoscaling groups where a handful of proxies will come up from the same enrollment token. * **0** (unlimited) fits base images or long-lived pools. Pair with a short `Key Expiry` so the blast radius of a leaked token stays bounded. ### Troubleshooting **Token expired.** The token's `Key Expiry` window has passed. Generate a new enrollment token and retry. **Max uses exceeded.** The enrollment token has already been redeemed `Max Uses` times. Generate a new token or increase `Max Uses` on the next one. ## Overview A single iron-proxy is a default-deny firewall for one workload. A fleet of them is a distributed system, and distributed systems need a control plane. The iron-proxy control plane is that layer. Policies are authored once and converge across every connected proxy in seconds. New proxies enroll on boot with a short-lived token and pull their config on first connect. There is nothing baked into the image and nothing to template per host. Every intercepted request from every proxy streams into a unified audit store you can query by workload, destination, policy decision, or injected secret. ### Architecture Operator authors policy Control Plane Policies · Enrollment · Audit Store hosted or self-hosted live config push audit events iron-proxy env:dev iron-proxy env:prod · ci iron-proxy tenant:acme workload CI runner workload build container workload agent sandbox The control plane is the single source of truth. Operators publish policies once; the control plane fans them out to every connected proxy in seconds. Each proxy enforces locally against its workload's egress and streams audit events back. New proxies enroll with a short-lived token and pick up the right policies based on the tags they carry. ### What You Get **Policy as the source of truth.** Allowlists, secret mappings, and transform rules live in the control plane and are versioned there. Proxies are stateless clients; rebuild a host and it pulls the current policy on reconnect. There is no per-host YAML to drift. **Live updates, no restarts.** Publish a policy change and every connected proxy applies it within seconds. Tightening an allowlist mid-incident or rotating a secret mapping is a single API call, not a fleet-wide redeploy. **Zero-config enrollment.** A proxy starts with `IRON_ENROLLMENT_TOKEN=…` and nothing else. Tokens are short-lived and single-use by default, so the same image safely boots in CI, in a sandbox, or in a long-lived VM. See [Enrollment](./enrollment) for the token model. **Fleet-wide audit search.** Every request across every proxy lands in one queryable store, indexed by host, workload tag, destination, decision, and the secrets that were proxied in. No per-host log shipping, no Loki cluster to operate. ### Deployment Options The control plane is available as a hosted cloud service and as a self-hosted on-prem instance. Both run the same software and expose the same API, UI, and proxy protocol, so moving between them is a config change rather than a rewrite. **Hosted.** Enroll proxies against a tenant endpoint and get policy management, live updates, enrollment, and audit search without operating anything yourself. Suited to teams that want a production-ready control plane without data-residency or network-isolation requirements. **Self-hosted.** Runs entirely inside your own infrastructure, with source available. Policy data and audit logs stay on your network, and the deployment can be fully air-gapped. Suited to regulated environments, on-prem-only networks, or any setting where a managed service is not an option. ### Next Steps [Book a demo](https://cal.com/matt-slipper-ironsh/15min) for a walkthrough of policy authoring, live updates, enrollment, and audit search against a real fleet. ## Policies A policy is a named allowlist that tells matching proxies which hosts, paths, and methods they can reach. Policies are authored in the control plane UI and delivered to every proxy whose tags match the policy's match set. Changes propagate within seconds of saving, without restarts. Proxies are default-deny. Anything not covered by an active, matching policy is rejected. ### Create a Policy :::steps #### Fill Out Policy Metadata In the control plane UI, open the **Policies** tab and click **Add Policy**. The New Policy form with Name, Priority, Status, and Match Tags fields Fill in: * **Name.** A human-readable identifier like `production-egress` or `ci-npm`. * **Priority.** An integer used to break ties when a single proxy matches more than one policy. Lower numbers win. Defaults to `10`. * **Status.** Set to `Active` to apply the policy to matching proxies. Any other status is a no-op, so you can draft a policy in the UI and hold it until ready. * **Match Tags.** The tags a proxy must carry for the policy to apply to it. Type a tag and press comma or enter to add it. Leave empty to apply the policy to every proxy in the fleet. #### Add Egress Rules Each rule describes a class of requests the policy allows. Click **Add Rule** once per rule. An egress rule with Host, Paths, and Methods fields Each rule has: * **Host** (required). A hostname or glob. `api.example.com` matches that host exactly. `*.example.com` matches any subdomain. * **Paths.** Zero or more path patterns. Leave empty to allow any path on the host. Add multiple paths by pressing comma or enter between entries. * **Methods.** The HTTP methods the rule accepts. Check **All** for every method, or pick a subset. Rules inside a policy are OR-combined: a request is allowed if it matches any rule in the policy. #### Save Save the policy. Every connected proxy with a matching tag set applies the change within seconds. Denied requests show up in the audit trail immediately. ::: ### Add a Secret Secrets let matching proxies inject or replace credentials at the network edge so the workload never sees the real value. Secrets are authored in the **Secrets** tab and are independent of policies: they have their own match-tag set and their own request rules, and apply to any proxy that matches, regardless of which policies that proxy carries. Click **Add Secret** to start the four-step wizard. :::steps #### Pick the Proxies Step 1 of the secret wizard: a tags input asking which proxies should hold this secret Type a tag and press comma or enter to add it. The credential is only loaded onto proxies that carry every tag in the set. Use the same tagging conventions you use for policy match tags: `env:prod`, `tenant:acme-corp`, `service:checkout`. Leave the field empty to load the secret onto every proxy in the fleet. #### Pick the Requests Step 2: a rule with Host, Path, and Methods fields scoping which requests get the secret Within the matched proxies, only requests that match one of these rules get the credential. Anything else passes through untouched. Each rule has the same fields as an egress rule: * **Host.** A hostname or glob, e.g. `api.openai.com` or `*.example.com`. * **Path.** Zero or more path patterns; leave empty to apply to any path. `/*` matches anything. * **Methods.** The HTTP methods the rule applies to. `ALL` covers every method. Click **+ Add rule** for additional rules. Rules are OR'd: a request matches if it matches any rule. Always scope as narrowly as you can. The blast radius of a misconfigured secret is exactly the union of these rules. #### Pick the Injection Style Step 3: presets for Bearer token, Basic auth, Custom, and Replace path token, with header and format fields Choose how the proxy rewrites the request in flight. Four presets cover the common cases: * **Bearer token.** Adds `Authorization: Bearer `. Standard for OpenAI, Anthropic, and most modern APIs. * **Basic auth.** Adds `Authorization: Basic base64(user:password)`. Use this for GitHub PAT auth and other Basic-auth services. * **Custom.** Inject into any header or query parameter, with a Go-template `Format` field that has access to `.Value` and a `base64` helper. Use this when none of the presets fit. * **Replace path token.** Swaps a placeholder you control out of the URL path for the real value before forwarding. Useful for upstreams like Telegram that put the credential in the path. The **Preview** at the bottom of the step shows exactly what the upstream will see, so you can verify the format before saving. #### Pick the Source Step 4: a Source dropdown with Environment variable selected and a Variable field Select where the proxy should resolve the real value from at startup. The value never appears in the control plane: it is read locally on the proxy host. iron-proxy supports several backends here, including environment variables, AWS Secrets Manager, AWS Systems Manager Parameter Store, and 1Password. See [Static Secrets](/credential-proxying/static-secrets) for the full set of source backends, the fields each one takes, TTL behavior, and credential-chain notes. ::: ### Match Tags A policy is pinned to a subset of the fleet by its match tags. A proxy is subject to a policy when it carries every tag in the policy's match set. A proxy can be matched by multiple policies at once, and their allowed rules combine. Because tags are assigned at [enrollment](./enrollment), you can roll out a new policy without touching its match set: enroll hosts with the right tag and they inherit the policy automatically. ### Priority When two matching policies disagree about the same host and method, the policy with the lower priority number wins, so `priority: 1` beats `priority: 10`. Most fleets don't need priority tuning: start with the default and adjust only if you see policies stepping on each other. ### Per-User and Per-Tenant Policies To scope a policy to a single user, customer, or tenant, encode the identity in tags. When you generate an [enrollment token](./enrollment) for a proxy that will run on behalf of a specific identity, include that identity in the token's tag set: `tenant:acme-corp`, `user:alice@example.com`, `customer-id:c_8f3a2b`. Every proxy registered with the token inherits those tags, and every audit event the proxy emits is indexed by them. Put that identity tag in the policy's match set. A policy with match tags `tenant:acme-corp` only applies to proxies that carry the `tenant:acme-corp` tag, so you can give each tenant a different egress allowlist by writing one policy per tenant. The same pattern works for per-user sandboxes (`user:alice@example.com`) or per-customer workloads in a multi-tenant runtime. Layered tagging gets you fleet-wide baselines plus per-identity overlays. A proxy can match multiple policies at once and the allowed rules combine, so a baseline policy with no match tags (applies to everyone) can grant common destinations like package registries, while per-tenant policies layer on the destinations only that tenant should reach. For compliance reporting, filter the audit trail by the same tenant or user tag to get a per-identity record of every request that proxy made. ### Rolling Out Safely Match tags make it cheap to canary a policy. Tag a single proxy with something like `canary:true`, point the new policy at `canary:true`, and watch the audit trail for denies before broadening the match set to include production tags. Audit search filtered by `decision: denied` shows exactly which requests the new policy would block. ## Self-Hosted The control plane runs inside your own network with the same software, API, and proxy protocol as the hosted version. Policy data, enrollment tokens, and audit logs never leave your infrastructure, and the deployment can be fully air-gapped. Source is available. A complete deployment bundle (container images, manifests, and an installation guide) is available on request. The page below covers the system requirements so you can size the host before reaching out. ### System Requirements The control plane is a single Docker container plus a database. There are no other required services. **Compute.** A single host or pod is enough for fleets up to a few hundred proxies. We recommend 4 vCPUs and 8 GB of memory. The container is stateless, so additional replicas can sit behind a load balancer without coordination if you need to scale beyond that. **Database.** PostgreSQL 14 or newer. Stores policies, enrollment tokens, proxy registrations, and tags. A managed instance (RDS, Cloud SQL, Neon, etc.) or a self-managed cluster both work. Plan for \~1 GB of storage for fleets up to a few thousand proxies; audit data goes elsewhere. **Audit log store (optional).** Audit events can be written to any OTLP-compatible log backend: Grafana Loki, Tempo, Honeycomb, Datadog, an OpenTelemetry collector fanning out to your own pipeline, or the bundled local store for evaluation. See [Configuring OTEL Export](/guides/otel-export) for the export format. If you skip this, audit search is limited to recent events held in memory. **Networking.** Inbound: HTTPS on a single port serves both the operator UI/API and the enrolled proxy connections. Outbound: only what you choose to allow (e.g. SSO, your audit backend). The control plane does not phone home. **TLS.** A certificate the proxies and operators can validate. Bring your own cert and key, or terminate at an ingress. ### Get the Deployment Bundle The self-hosted bundle ships under a source-available license with a short evaluation agreement. [Get in touch](https://cal.com/matt-slipper-ironsh/15min) and we'll walk through requirements, send the bundle, and stay on for the first install. ## MCP Policies API MCP policies enforce default-deny tool allowlists on Streamable HTTP MCP servers. See [MCP Interception](/policies/mcp-interception) for the runtime semantics, JSON-RPC handling, and `tools/list` filtering behavior. Each policy targets a subset of the fleet by tag, scopes which requests it applies to with `rules`, and lists the tools an agent is allowed to call along with optional argument matchers. Base path: `/v1/policies/mcp` ### The MCP Policy Object ```json { "id": "mpol_01H...", "name": "github-mcp", "active": true, "priority": 0, "match_tags": ["production"], "rules": [ { "host": "mcp.github.com", "paths": ["/mcp", "/mcp/*"], "methods": ["POST"] } ], "tools": [ { "name": "search_repositories", "matchers": [] }, { "name": "create_issue", "matchers": [ { "path": "owner", "equals": "ironsh" }, { "path": "repo", "in": ["iron-proxy", "tunis-v2"] } ] } ], "created_at": "2026-05-08T12:00:00Z", "updated_at": "2026-05-08T12:00:00Z" } ``` #### Fields | Field | Type | Description | | ------------ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | Server-assigned opaque ID, prefixed with `mpol_`. | | `name` | string | URL-safe name, unique within the organization. Must match `[a-z0-9]+(-[a-z0-9]+)*`. | | `active` | boolean | When `false`, the policy is stored but not delivered to proxies. Defaults to `true`. | | `priority` | integer | Tie-breaker when more than one policy applies. Lower wins. Required. | | `match_tags` | string\[] | Tags a proxy must carry for the policy to apply. Empty applies to every proxy. | | `rules` | object\[] | Request rules that scope which traffic the MCP interceptor handles. Same shape as [network policy rules](/control-plane/api/network-policies#rule-object). | | `tools` | object\[] | Allowlisted tools. Tools not listed are denied. | | `created_at` | string | RFC 3339 timestamp. | | `updated_at` | string | RFC 3339 timestamp. | #### Tool Object ```json { "name": "create_issue", "matchers": [ { "path": "owner", "equals": "ironsh" }, { "path": "repo", "in": ["iron-proxy", "tunis-v2"] }, { "path": "title", "matches": "^\\[bot\\]" } ] } ``` | Field | Type | Description | | ---------- | --------- | ---------------------------------------------------------------------------------------------------------- | | `name` | string | The MCP tool name. Required. | | `matchers` | object\[] | Argument constraints. All matchers must pass for a `tools/call` to be allowed. Empty allows any arguments. | #### Matcher Object A matcher selects a value from `params.arguments` by `path` (dotted notation) and applies exactly one of `equals`, `in`, or `matches`. | Field | Type | Description | | --------- | ------ | -------------------------------------------------------------------- | | `path` | string | Dotted path into the tool arguments. Required. | | `equals` | any | Argument at `path` must equal this value. | | `in` | any\[] | Argument at `path` must be one of these values. | | `matches` | string | Argument at `path`, stringified, must match this regular expression. | Set exactly one of `equals`, `in`, `matches` per matcher. Sending more than one returns `422` with code `validation_error`. ### List MCP Policies ```http GET /v1/policies/mcp ``` Returns every MCP policy in the calling organization, ordered by `priority` ascending. #### Query Parameters | Name | Type | Description | | -------- | ------- | ------------------------------------------------------------ | | `name` | string | Exact match on policy name. | | `active` | boolean | When supplied, returns only policies with that active state. | ```sh curl https://api.iron.sh/v1/policies/mcp \ -H "Authorization: Bearer $IRON_API_KEY" ``` ### Create an MCP Policy ```http POST /v1/policies/mcp ``` #### Request Body | Field | Type | Required | | ------------ | --------- | -------- | | `name` | string | Yes | | `priority` | integer | Yes | | `active` | boolean | No | | `match_tags` | string\[] | No | | `rules` | object\[] | No | | `tools` | object\[] | No | #### Example ```sh curl https://api.iron.sh/v1/policies/mcp \ -H "Authorization: Bearer $IRON_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "linear-mcp", "priority": 42, "match_tags": ["production", "linear"], "rules": [ { "host": "mcp.linear.app", "paths": ["/mcp"], "methods": ["POST"] } ], "tools": [ { "name": "create_issue", "matchers": [ { "path": "team", "in": ["eng", "infra"] }, { "path": "title", "matches": "^\\[bot\\]" } ] } ] }' ``` Returns `201 Created` with the new policy in `data`. ### Retrieve an MCP Policy ```http GET /v1/policies/mcp/:id ``` Returns `200 OK` with the policy in `data`, or `404 Not Found` with code `mcp_policy_not_found`. ### Update an MCP Policy ```http PUT /v1/policies/mcp/:id ``` `GET` the policy, modify the fields you want to change, and `PUT` the full representation back. Send the same fields you would on create. #### Example ```sh curl -X PUT https://api.iron.sh/v1/policies/mcp/mpol_01H... \ -H "Authorization: Bearer $IRON_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "github-mcp", "priority": 0, "active": false, "match_tags": ["production"], "rules": [ { "host": "mcp.github.com", "paths": ["/mcp", "/mcp/*"], "methods": ["POST"] } ], "tools": [ { "name": "search_code", "matchers": [{ "path": "repo", "equals": "console" }] } ] }' ``` ### Delete an MCP Policy ```http DELETE /v1/policies/mcp/:id ``` Returns `204 No Content`. Connected proxies stop applying the policy within seconds. ## Network Policies API Network policies are named egress allowlists. Each policy targets a subset of the fleet by tag and lists the hosts, paths, and methods those proxies are allowed to reach. See [Policies](/control-plane/policies) for the conceptual overview. Base path: `/v1/policies/network` ### The Network Policy Object ```json { "id": "npol_01H8XYZ...", "name": "default-egress", "active": true, "priority": 0, "match_tags": ["production"], "rules": [ { "host": "*.example.com", "paths": [], "methods": [] } ], "version": 1, "created_at": "2026-05-08T12:00:00Z", "updated_at": "2026-05-08T12:00:00Z" } ``` #### Fields | Field | Type | Description | | ------------ | --------- | ------------------------------------------------------------------------------------------------ | | `id` | string | Server-assigned opaque ID, prefixed with `npol_`. | | `name` | string | URL-safe name, unique within the organization. Must match `[a-z0-9]+(-[a-z0-9]+)*`. | | `active` | boolean | When `false`, the policy is stored but not delivered to proxies. Defaults to `true`. | | `priority` | integer | Tie-breaker when more than one policy applies to the same proxy. Lower wins. Required. | | `match_tags` | string\[] | Tags a proxy must carry for the policy to apply. Empty applies to every proxy. Defaults to `[]`. | | `rules` | object\[] | Egress rules. See [Rule object](#rule-object). Defaults to `[]`. | | `version` | integer | Schema version for the rule body. Defaults to `1`. | | `created_at` | string | RFC 3339 timestamp. | | `updated_at` | string | RFC 3339 timestamp. | #### Rule Object ```json { "host": "api.example.com", "paths": ["/v1/*"], "methods": ["GET", "POST"] } ``` | Field | Type | Description | | --------- | --------- | -------------------------------------------------------------------------- | | `host` | string | Hostname or wildcard, e.g. `api.example.com` or `*.example.com`. Required. | | `paths` | string\[] | Path patterns. Empty allows any path. | | `methods` | string\[] | HTTP methods. Empty allows any method. | ### List Network Policies ```http GET /v1/policies/network ``` Returns every network policy in the calling organization, ordered by `priority` ascending. #### Query Parameters | Name | Type | Description | | -------- | ------- | ------------------------------------------------------------ | | `name` | string | Exact match on policy name. | | `active` | boolean | When supplied, returns only policies with that active state. | #### Example ```sh curl https://api.iron.sh/v1/policies/network \ -H "Authorization: Bearer $IRON_API_KEY" ``` ```json { "data": [ { "id": "npol_01H...", "name": "default-egress", "active": true, "priority": 0, "match_tags": ["production"], "rules": [{ "host": "*.example.com", "paths": [], "methods": [] }], "version": 1, "created_at": "2026-05-08T12:00:00Z", "updated_at": "2026-05-08T12:00:00Z" } ] } ``` ### Create a Network Policy ```http POST /v1/policies/network ``` #### Request Body | Field | Type | Required | | ------------ | --------- | -------- | | `name` | string | Yes | | `priority` | integer | Yes | | `active` | boolean | No | | `match_tags` | string\[] | No | | `rules` | object\[] | No | | `version` | integer | No | #### Example ```sh curl https://api.iron.sh/v1/policies/network \ -H "Authorization: Bearer $IRON_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "api-egress", "priority": 42, "match_tags": ["production", "api"], "rules": [ { "host": "api.example.com", "paths": ["/v1/*"], "methods": ["GET", "POST"] } ] }' ``` Returns `201 Created` with the new policy in `data`. ### Retrieve a Network Policy ```http GET /v1/policies/network/:id ``` ```sh curl https://api.iron.sh/v1/policies/network/npol_01H... \ -H "Authorization: Bearer $IRON_API_KEY" ``` Returns `200 OK` with the policy in `data`, or `404 Not Found` with code `network_policy_not_found`. ### Update a Network Policy ```http PUT /v1/policies/network/:id ``` `GET` the policy, modify the fields you want to change, and `PUT` the full representation back. Send the same fields you would on create. #### Example ```sh curl -X PUT https://api.iron.sh/v1/policies/network/npol_01H... \ -H "Authorization: Bearer $IRON_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "default-egress", "priority": 0, "active": false, "match_tags": ["staging"], "rules": [ { "host": "staging.example.com", "paths": ["*"], "methods": ["GET"] } ], "version": 1 }' ``` ### Delete a Network Policy ```http DELETE /v1/policies/network/:id ``` ```sh curl -X DELETE https://api.iron.sh/v1/policies/network/npol_01H... \ -H "Authorization: Bearer $IRON_API_KEY" ``` Returns `204 No Content`. Connected proxies stop applying the policy within seconds. ## API Overview The control plane exposes a JSON API for managing policies, proxies, and other fleet resources. Anything you can do in the control plane UI under **Policies** is available over the API. ### Base URL All API requests are made against the `api` subdomain of your control plane: ``` https://api.iron.sh/v1 ``` Self-hosted deployments substitute their own host. The API path layout (`/v1/...`) is identical. ### Authentication The API uses bearer tokens. Create an API key in the control plane UI under **API Keys**, then include it in the `Authorization` header on every request: ```http Authorization: Bearer ``` API keys are scoped to the organization that created them. Requests with a missing or invalid token return `401 Unauthorized`. ### Content Type `POST`, `PUT`, and `PATCH` requests must send `Content-Type: application/json`. The API rejects other content types with `415 Unsupported Media Type`: ```json { "error": { "code": "unsupported_media_type", "message": "Content-Type must be application/json" } } ``` ### Identifiers Every resource is addressed by an opaque, prefixed ID. Network policies start with `npol_`, secret policies with `spol_`, and MCP policies with `mpol_`. Treat IDs as opaque strings: do not parse, generate, or assume anything about their format beyond the prefix. ### Errors Errors return a JSON body with a stable `code` and a human-readable `message`: ```json { "error": { "code": "validation_error", "message": "Name has already been taken" } } ``` Common codes across policy endpoints: | Status | Code | Meaning | | ------ | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | | 401 | `unauthorized` | Missing or invalid API key. | | 404 | `network_policy_not_found`, `secret_policy_not_found`, `mcp_policy_not_found` | The ID does not exist or belongs to a different organization. | | 409 | `network_policy_name_taken`, `secret_policy_name_taken`, `mcp_policy_name_taken` | A policy with that name already exists in the organization. | | 415 | `unsupported_media_type` | `Content-Type` is not `application/json`. | | 422 | `validation_error` | The request body failed validation. The `message` lists the failing fields. | ### Server-Owned Fields The API ignores fields that are owned by the server: `id`, `organization_id`, `created_by_id`, `created_at`, `updated_at`. Sending them in `POST` or `PATCH` bodies is harmless: the values you supply are dropped and the server fills the canonical values in the response. ### Listing and Filtering List endpoints return all matching resources in a single response under `data`, ordered by `priority` ascending: ```json { "data": [ { "id": "npol_...", "name": "default-egress", ... } ] } ``` Most list endpoints accept `name` and `active` query parameters as exact-match filters. ### Resources * [Network Policies](/control-plane/api/network-policies) * [Secret Policies](/control-plane/api/secret-policies) * [MCP Policies](/control-plane/api/mcp-policies) ## Secret Policies API Secret policies tell matching proxies how to apply a credential at the egress boundary, so workloads never see the real value. See [Credential Proxying](/credential-proxying/overview) for the runtime semantics, and [Static Secrets](/credential-proxying/static-secrets) for source backends. Each policy is either in `inject` mode (the proxy adds a header or query parameter to matching requests) or `replace` mode (the proxy swaps a placeholder token in the URL path for the real value). The two modes are mutually exclusive: a policy carries either an `inject_config` or a `replace_config`, never both. Base path: `/v1/policies/secrets` ### The Secret Policy Object Inject mode: ```json { "id": "spol_01H...", "name": "openai-key", "active": true, "priority": 0, "match_tags": ["production"], "mode": "inject", "rules": [ { "host": "api.openai.com", "paths": ["/v1/*"], "methods": ["POST"] } ], "source": { "type": "env", "var": "OPENAI_API_KEY" }, "inject_config": { "header": "Authorization", "formatter": "Bearer {{ .Value }}" }, "created_at": "2026-05-08T12:00:00Z", "updated_at": "2026-05-08T12:00:00Z" } ``` Replace mode: ```json { "id": "spol_01H...", "name": "telegram-bot", "active": true, "priority": 0, "match_tags": ["production"], "mode": "replace", "rules": [ { "host": "api.telegram.org", "paths": ["/bot*"], "methods": [] } ], "source": { "type": "env", "var": "TELEGRAM_BOT_TOKEN" }, "replace_config": { "proxy_value": "PROXY-BOT-TOKEN" }, "created_at": "2026-05-08T12:00:00Z", "updated_at": "2026-05-08T12:00:00Z" } ``` #### Fields | Field | Type | Description | | ---------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | string | Server-assigned opaque ID, prefixed with `spol_`. | | `name` | string | URL-safe name, unique within the organization. Must match `[a-z0-9]+(-[a-z0-9]+)*`. | | `active` | boolean | When `false`, the policy is stored but not delivered to proxies. Defaults to `true`. | | `priority` | integer | Tie-breaker when more than one policy applies. Lower wins. Required. | | `match_tags` | string\[] | Tags a proxy must carry for the policy to apply. Empty applies to every proxy. | | `mode` | string | `"inject"` or `"replace"`. | | `rules` | object\[] | Request rules that scope which traffic the credential applies to. Same shape as [network policy rules](/control-plane/api/network-policies#rule-object). | | `source` | object | Where the proxy reads the real credential from. See [Source object](#source-object). | | `inject_config` | object | Present only in `inject` mode. See [Inject config](#inject-config). | | `replace_config` | object | Present only in `replace` mode. See [Replace config](#replace-config). | | `created_at` | string | RFC 3339 timestamp. | | `updated_at` | string | RFC 3339 timestamp. | #### Source Object The `source` object tells the proxy where to resolve the real credential at startup. The exact set of fields depends on the source `type`. See [Static Secrets / Secret Sources](/credential-proxying/static-secrets#secret-sources) for the full list of supported backends. | Field | Type | Description | | ----------------- | ------- | ------------------------------------------------------------------------ | | `type` | string | One of `env`, `aws_sm`, `aws_ssm`. Defaults to `env`. | | `var` | string | Environment variable name. Required when `type` is `env`. | | `secret_id` | string | AWS Secrets Manager ARN. Required when `type` is `aws_sm`. | | `name` | string | Parameter Store name. Required when `type` is `aws_ssm`. | | `region` | string | AWS region override. | | `json_key` | string | JSON key to extract from a JSON-encoded secret value. | | `ttl` | string | Cache TTL like `15m`, `1h`. Format: digits followed by `h`, `m`, or `s`. | | `with_decryption` | boolean | Decrypt SecureString parameters (Parameter Store only). | #### Inject Config Used when `mode` is `"inject"`. Set either `header` or `query_param`, not both. | Field | Type | Description | | ------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------- | | `header` | string | Header name to set. | | `query_param` | string | Query parameter name to append. | | `formatter` | string | Go template that produces the header value. Receives `.Value` and a `base64` helper. Required for non-trivial header formats. | #### Replace Config Used when `mode` is `"replace"`. The proxy looks for `proxy_value` in the URL path and replaces it with the resolved secret before forwarding. | Field | Type | Description | | ------------- | ------ | ----------------------------------------------------------------------------------------------------------------- | | `proxy_value` | string | URL-safe placeholder that appears in workload requests. Must be alphanumeric, `-._~`, or `%XX` escapes. Required. | ### List Secret Policies ```http GET /v1/policies/secrets ``` Returns every secret policy in the calling organization, ordered by `priority` ascending. #### Query Parameters | Name | Type | Description | | -------- | ------- | ------------------------------------------------------------ | | `name` | string | Exact match on policy name. | | `active` | boolean | When supplied, returns only policies with that active state. | ```sh curl https://api.iron.sh/v1/policies/secrets \ -H "Authorization: Bearer $IRON_API_KEY" ``` ### Create a Secret Policy ```http POST /v1/policies/secrets ``` #### Request Body | Field | Type | Required | | ---------------- | --------- | --------------------------- | | `name` | string | Yes | | `priority` | integer | Yes | | `mode` | string | Yes (`inject` or `replace`) | | `source` | object | Yes | | `inject_config` | object | When `mode` is `inject` | | `replace_config` | object | When `mode` is `replace` | | `active` | boolean | No | | `match_tags` | string\[] | No | | `rules` | object\[] | No | Submitting a payload with both `inject_config` and `replace_config` is allowed: the server clears the config that does not match `mode`. Submit only the one for the mode you set to keep request bodies obvious. #### Example: Bearer Token Injection ```sh curl https://api.iron.sh/v1/policies/secrets \ -H "Authorization: Bearer $IRON_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "stripe-key", "priority": 42, "mode": "inject", "match_tags": ["production", "payments"], "rules": [ { "host": "api.stripe.com", "paths": ["/v1/*"], "methods": ["POST"] } ], "source": { "type": "env", "var": "STRIPE_API_KEY" }, "inject_config": { "header": "Authorization", "formatter": "Bearer {{ .Value }}" } }' ``` #### Example: Path Token Replacement ```sh curl https://api.iron.sh/v1/policies/secrets \ -H "Authorization: Bearer $IRON_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "telegram-bot", "priority": 50, "mode": "replace", "rules": [ { "host": "api.telegram.org", "paths": ["/bot*"], "methods": [] } ], "source": { "type": "env", "var": "TELEGRAM_BOT_TOKEN" }, "replace_config": { "proxy_value": "PROXY-BOT-TOKEN" } }' ``` Returns `201 Created` with the new policy in `data`. ### Retrieve a Secret Policy ```http GET /v1/policies/secrets/:id ``` Returns `200 OK` with the policy in `data`, or `404 Not Found` with code `secret_policy_not_found`. ### Update a Secret Policy ```http PUT /v1/policies/secrets/:id ``` `GET` the policy, modify the fields you want to change, and `PUT` the full representation back. Send the same fields you would on create. To switch between modes, change `mode` and supply the matching `inject_config` or `replace_config`: the server clears the other config block automatically. #### Example: Switch from Inject to Replace ```sh curl -X PUT https://api.iron.sh/v1/policies/secrets/spol_01H... \ -H "Authorization: Bearer $IRON_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "openai-key", "priority": 0, "active": true, "match_tags": ["production"], "mode": "replace", "rules": [ { "host": "api.openai.com", "paths": ["/v1/*"], "methods": ["POST"] } ], "source": { "type": "env", "var": "OPENAI_API_KEY" }, "replace_config": { "proxy_value": "proxy-openai-token" } }' ``` ### Delete a Secret Policy ```http DELETE /v1/policies/secrets/:id ``` Returns `204 No Content`. Connected proxies stop applying the policy within seconds.