Allowed IP Filter
The allowed_ip filter provides network-level access control based on the
client’s IP address. It can operate as an allowlist, a blocklist, or both.
How it works
This filter implements standard allow/deny semantics using CIDR matching:
- Deny wins: The
denylist is checked first. If a client matches any CIDR in the deny list, the request is immediately rejected. - Allow list: If an
allowlist is provided, the client must match at least one CIDR. If the list is empty, no allow constraint is imposed (functioning purely as a blocklist).
Client IP resolution & proxies
acme-proxy resolves the client IP securely. If the server is behind a reverse
proxy, the proxy must be trusted. Trusted proxies are declared under
[filter], not [server]:
[filter]
trusted_proxies = ["10.0.0.0/8"]
forwarded_header = "x-forwarded-for" # the default
Get the section right. Unknown keys are ignored rather than rejected, so
trusted_proxieswritten under[server]is silently dropped — no warning, no startup error. The forwarded header is then never believed, and every request is attributed to the reverse proxy’s own address instead of the client’s.
When a connection originates from a trusted proxy, acme-proxy walks the
forwarded-for header right-to-left, skipping trusted hops until it finds the
true client IP. Requests arriving from an address that is not in
trusted_proxies are attributed to their peer address, and any forwarded header
they carry is ignored — which is what makes a spoofed X-Forwarded-For header
useless. IP addresses are canonicalized internally (IpAddr::to_canonical()),
meaning IPv4-mapped IPv6 addresses (e.g., ::ffff:192.168.1.1) are properly
treated as IPv4.
Important (Fail Closed): The filter subsystem operates with strict fail-closed semantics. If the client IP cannot be determined (e.g., misconfigured reverse proxy or missing
TapIowrapper),ConnectionContext::require_client_ip()fails. The filter will deny access rather than assuming the client is safe.
Configuration
[filter]
rules = ["internal-only"]
[filter.check.internal-nets]
type = "allowed_ip"
# Deny external bad actors
deny = ["203.0.113.9", "198.51.100.0/24"]
# Allow internal networks
allow = ["192.168.1.0/24", "10.0.0.0/8", "fd00::/8"]
[filter.rule.internal-only]
when = "internal-nets"
then = "allow"
allow and deny take CIDRs or bare addresses (a bare address becoming a host
route), IPv4 or IPv6. Both empty while a rule names the check is a startup
error: an empty allow imposes no constraint, so the check would accept
everything, and an operator who configured it did not mean to turn on something
inert.
The shared allow/deny semantics, and the keys themselves, are documented under Checks.
This check answers at both stages, since it reads nothing but the client
address — which is what lets a rule say internal-nets or inventory and still
have the address half answerable once the requested names are known.