Reverse proxies: nginx, Traefik, Caddy, Envoy
The CDN proxy runs the edge inside Cloudflare, Fastly, Azure
or CloudFront. If what stands in front of your site is a reverse proxy you run
yourself, it cannot run that code. It can ask a service before it passes a
request on, though: nginx with auth_request, Traefik with ForwardAuth, Caddy
with forward_auth, and Envoy (and Istio) with ext_authz all work this way.
The forward-auth service is that service. It is the same edge core every CDN adapter runs, packaged as a small Node server. The proxy asks it about each request:
- 200 means let the request through. The verdict comes back in
Antibot-Assess-*headers for the proxy to copy onto the request it passes on. - Anything else is the edge's own answer, for the proxy to send back to the visitor. That can be a refusal, the challenge interstitial, or the waiting room's redirect.
The proxy also routes the mount (/_ab/*: the agent, identification, the
trap) to the service as an ordinary upstream, so the agent stays first-party.
Every configuration below was run as written: the service bundled as its
container runs it, each proxy started from its own release, and requests made
through it to a stand-in site (proxy/test/reverse-proxies.mjs, in CI).
Envoy is the exception, and its section says so.
Run the service
# From the repository's root:
docker build -f proxy/forward-auth.Dockerfile -t antibot-forward-auth proxy/
docker run -p 8080:8080 --env-file antibot.env antibot-forward-auth
Without Docker, run pnpm build:forward-auth in proxy/, then
node dist/forward-auth.mjs. Both give one file of JavaScript with no
dependencies at run time.
It reads the same settings as every adapter (see
Edge enforcement): ANTIBOT_ORIGIN,
ANTIBOT_PROXY_SECRET, ANTIBOT_ORG_ID, ANTIBOT_SESSION_KEYS,
ANTIBOT_ENFORCE_PATHS, ANTIBOT_ENFORCE_MISSING, ANTIBOT_ENFORCE_STEP_UP, ANTIBOT_ASSESS_SECRET_KEY,
ANTIBOT_PUBLIC_KEY, ANTIBOT_LOGIN_FIELDS and the rest. On top of those:
| Setting | |
|---|---|
ANTIBOT_FORWARD_SECRET |
Required. A long random value the proxy sends as Antibot-Forward-Secret on every request to the service. Without a match, the service refuses everything. |
ANTIBOT_FORWARD_PROXY |
Required. nginx, traefik, caddy or envoy: which proxy is in front. It decides which headers are believed (below). |
ANTIBOT_FORWARD_TRUSTED_HOPS |
Behind Traefik or Caddy with a load balancer in front of them: how many. The visitor's address is then the X-Forwarded-For entry that many places from the right. Default 0. |
ANTIBOT_FORWARD_TLS |
on when your proxy's configuration sets every Antibot-Tls-* header (nginx and Envoy can). Off by default, because otherwise a visitor could hand itself a browser's handshake. |
ANTIBOT_FORWARD_RTT |
on when your proxy sets Antibot-Tcp-Rtt on the mount — the connection's smoothed round trip in microseconds, nginx's $tcpinfo_rtt (Traefik, Caddy and Envoy expose none) — so the service can time the browser against it (the browser's distance). Off by default: a visitor that wrote its own would hide the proxy it comes through. |
ANTIBOT_STUN_PORT |
A UDP port on which the service answers the agent's WebRTC check itself — a STUN binding responder and nothing more — so that no public STUN server (Google's and Cloudflare's, by default) sees your visitors' addresses. Publish the port (docker run -p 3478:3478/udp …) and point the agent at it: webrtc: { stun: ["stun:stun.your-site.example:3478"] }. Each source address is answered ten times a second at most, so a forged source cannot aim much traffic through it. Off by default. |
PORT, HOST |
Where it listens. Default 8080 on 0.0.0.0. |
GET /_antibot/health answers 200 once the secret and the proxy are set, and
503 until then.
Stopped (SIGTERM, as docker stop, ECS and Kubernetes stop it), the service
answers health with 503 at once and goes on answering for two seconds, while
the load balancer or the proxy stops sending to it. Then it takes no new
connections, finishes the requests under way (five seconds at most), sends
the receipts it has let through since its last report, and exits, all within
the ten seconds docker stop gives. A proxy in front never has a request it
sent cut off mid-answer, so a rolling deploy shows visitors no errors.
What is believed
The question reaches the service carrying the visitor's own headers next to the proxy's. Any header the proxy does not set is one a visitor can write. So each proxy is read only through the headers its configuration below sets or overwrites, and only when the request carries the secret:
ANTIBOT_FORWARD_PROXY |
The request asked about | The visitor's address |
|---|---|---|
nginx |
X-Forwarded-Method, -Uri, -Host, -Proto, set by the configuration |
Antibot-Client-Ip, set from $remote_addr |
traefik, caddy |
X-Forwarded-Method, -Uri, -Host, -Proto, which they set themselves |
The X-Forwarded-For entry they appended (see ANTIBOT_FORWARD_TRUSTED_HOPS) |
envoy |
The method and path it asks with, and Antibot-Original-Host |
Antibot-Client-Ip, which the configuration adds |
A visitor's own Antibot-Client-Ip, X-Forwarded-*, X-Original-*, TLS
headers or Antibot-Forward-Mode changes nothing.
Protected paths and configured forms are matched the way a site might read
the path, not as it was written. Percent-encoding is undone, dot segments are
resolved, and case is ignored. So /%73hop, /SHOP and /_ab/..%2Fshop are
all /shop.
Keep the service off the public network as well.
Serve only the hostnames that are yours. A proxy that answers any Host — an
nginx default_server that passes requests on, a catch-all route — lets a
request name a site it was not sent to, and a Web Bot Auth signature binds the
host the request names: an agent's signature captured from another site would
then verify here, as that agent, for the five minutes it is fresh. Refuse what
you do not serve before it is asked about (server { listen 443 ssl default_server; ssl_reject_handshake on; } in nginx; a router per host in
Traefik and Caddy, which is their default). With your sites listed as your
organization's allowed origins, a signature made for a site that is not one
of them is not counted as a verified agent, whatever the proxy lets in.
nginx
# The mount: the agent, identification, the trap.
location /_ab/ {
proxy_pass http://antibot:8080;
proxy_set_header Host $host;
proxy_set_header Antibot-Forward-Secret "<ANTIBOT_FORWARD_SECRET>";
proxy_set_header Antibot-Client-Ip $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
# With ANTIBOT_FORWARD_RTT=on (Linux, FreeBSD, macOS):
# proxy_set_header Antibot-Tcp-Rtt $tcpinfo_rtt;
}
# The question, asked before every request below.
location = /_antibot_auth {
internal;
proxy_pass http://antibot:8080/_antibot/auth;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Host $host;
proxy_set_header Antibot-Forward-Secret "<ANTIBOT_FORWARD_SECRET>";
proxy_set_header Antibot-Client-Ip $remote_addr;
proxy_set_header X-Forwarded-Method $request_method;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
# With TLS terminated here, and ANTIBOT_FORWARD_TLS=on:
# proxy_set_header Antibot-Tls-Version $ssl_protocol;
# proxy_set_header Antibot-Tls-Cipher $ssl_cipher;
# proxy_set_header Antibot-Tls-Alpn $ssl_alpn_protocol;
}
# The edge's own answer to a refused request: the interstitial, a 403, a decoy.
location @antibot {
rewrite ^ /_antibot/answer break;
proxy_pass http://antibot:8080;
proxy_set_header Antibot-Decision $antibot_decision;
proxy_set_header Host $host;
proxy_set_header Antibot-Forward-Secret "<ANTIBOT_FORWARD_SECRET>";
proxy_set_header Antibot-Client-Ip $remote_addr;
proxy_set_header X-Forwarded-Method $request_method;
proxy_set_header X-Forwarded-Uri $request_uri;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
auth_request /_antibot_auth;
auth_request_set $antibot_decision $upstream_http_antibot_decision;
auth_request_set $antibot_action $upstream_http_antibot_assess_action;
auth_request_set $antibot_score $upstream_http_antibot_assess_score;
auth_request_set $antibot_reasons $upstream_http_antibot_assess_reasons;
auth_request_set $antibot_id $upstream_http_antibot_assess_id;
auth_request_set $antibot_would_act $upstream_http_antibot_assess_would_act;
auth_request_set $antibot_would_reason $upstream_http_antibot_assess_would_reason;
error_page 403 = @antibot;
# Set from the verdict, or not at all: a client's own copy never reaches the site.
proxy_set_header Antibot-Assess-Action $antibot_action;
proxy_set_header Antibot-Assess-Score $antibot_score;
proxy_set_header Antibot-Assess-Reasons $antibot_reasons;
proxy_set_header Antibot-Assess-Id $antibot_id;
proxy_set_header Antibot-Assess-Would-Act $antibot_would_act;
proxy_set_header Antibot-Assess-Would-Reason $antibot_would_reason;
proxy_set_header Host $host;
proxy_pass http://your-site;
}
auth_request accepts only 2xx, 401 and 403 from the question. A redirect or
a page would read as an error. So with ANTIBOT_FORWARD_PROXY=nginx, the
service answers every refusal with a bare 403 that names a decision. nginx's
error_page then fetches that decision's real answer from @antibot. That
answer is the interstitial, a refusal, or a decoy that is still a 200.
A decision waits 30 seconds for that fetch. If the fetch reaches another
instance of the service, or arrives later, the request is decided again. If
it would now be let through, the visitor is sent back to make it again: a 307,
so a form is posted again. A 403 that nginx makes itself, from a deny rule
for example, also reaches error_page, but it names no decision, so it stays
a 403.
Set ANTIBOT_FORWARD_PROXY=nginx. auth_request sends no body, so a
configured sign-in is assessed without its account (see
What it cannot do).
Traefik
http:
routers:
antibot-mount:
rule: "PathPrefix(`/_ab/`)"
service: antibot
middlewares: [antibot-secret]
priority: 1000
# The configured forms, whose body the question needs: forwardBody here
# only, so an upload anywhere else is never held to its size.
forms:
rule: "Method(`POST`) && Path(`/login`)"
service: site
middlewares: [antibot-secret, antibot-forms, antibot-secret-off]
priority: 100
site:
rule: "PathPrefix(`/`)"
service: site
middlewares: [antibot-secret, antibot, antibot-secret-off]
middlewares:
antibot-secret:
headers:
customRequestHeaders:
Antibot-Forward-Secret: "<ANTIBOT_FORWARD_SECRET>"
# ForwardAuth asks with the request's own headers, so the secret is added
# before it and taken off after it: the site never sees it.
antibot-secret-off:
headers:
customRequestHeaders:
Antibot-Forward-Secret: ""
antibot:
forwardAuth:
address: "http://antibot:8080/_antibot/auth"
authResponseHeaders: &verdict
- Antibot-Assess-Action
- Antibot-Assess-Score
- Antibot-Assess-Reasons
- Antibot-Assess-Id
- Antibot-Assess-Would-Act
- Antibot-Assess-Would-Reason
antibot-forms:
forwardAuth:
address: "http://antibot:8080/_antibot/auth"
forwardBody: true
maxBodySize: 65536
authResponseHeaders: *verdict
services:
antibot:
loadBalancer:
servers: [{ url: "http://antibot:8080" }]
site:
loadBalancer:
servers: [{ url: "http://your-site" }]
Set ANTIBOT_FORWARD_PROXY=traefik. Traefik sends back whatever the service
answers to a refused request, so the
interstitial and refusals reach the visitor as they are. A decoy is the one
exception. At the edge a decoy is a 200, but here a 200 means "let it
through", so it is sent as a 403.
The service always answers a 200 with all six verdict headers (the four of the
verdict, and monitor mode's two would- headers), empty when there is none. authResponseHeaders then overwrites a client's own
copies.
forwardBody (in recent Traefik 3 releases; tested with 3.5) sends the
visitor's body with the question, so a configured sign-in's account is read.
Put it on the routes of your configured forms only. Traefik refuses a body
over maxBodySize outright, so on every route it would turn away every large
upload on the site.
Caddy
example.com {
handle /_ab/* {
reverse_proxy antibot:8080 {
header_up Antibot-Forward-Secret <ANTIBOT_FORWARD_SECRET>
}
}
handle {
route {
request_header -Antibot-Assess-*
request_header -Antibot_*
forward_auth antibot:8080 {
uri /_antibot/auth
header_up Antibot-Forward-Secret <ANTIBOT_FORWARD_SECRET>
copy_headers Antibot-Assess-Action Antibot-Assess-Score Antibot-Assess-Reasons Antibot-Assess-Id Antibot-Assess-Would-Act Antibot-Assess-Would-Reason
}
reverse_proxy your-site:80
}
}
}
Three details matter here, and the tests found all three:
- Remove the client's own verdict headers first. Caddy copies only the headers that come back with a value, so an empty verdict would leave a client's own copy in place.
- Remove them however they are spelled. To CGI and FastCGI,
antibot_assess_actionandAntibot-Assess-Actionare the same variable,HTTP_ANTIBOT_ASSESS_ACTION. Behindphp_fastcgi(or FrankenPHP) a client's underscored copy reached PHP in place of the verdict, whichever came first — measured with Caddy 2.10 and php-cgi 8.3. The service refuses a request carrying one, andrequest_header -Antibot_*removes them before anything is asked. nginx drops headers with underscores unlessunderscores_in_headersis on; leave it off. - Keep everything inside
route. Caddy's default directive order runsrequest_headerafterforward_auth, which would delete the verdict it had just copied.
Refused requests are sent back as Traefik sends them, decoys included. Set
ANTIBOT_FORWARD_PROXY=caddy.
forward_auth sends no body.
Envoy and Istio
Not yet run against a real Envoy: this machine has none, and CI does not run one yet. The service's handling of Envoy's request shape is unit-tested (
proxy/test/forward-auth.test.mjs); the configuration below is not.
http_filters:
# A visitor's own verdict headers go first: when the question fails and
# failure_mode_allow lets the request through, none of theirs reaches the site.
- name: envoy.filters.http.header_mutation
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.header_mutation.v3.HeaderMutation
mutations:
request_mutations:
- remove: antibot-assess-action
- remove: antibot-assess-score
- remove: antibot-assess-reasons
- remove: antibot-assess-id
- remove: antibot-assess-would-act
- remove: antibot-assess-would-reason
# And in the HTTP connection manager, beside these filters:
# common_http_protocol_options: { headers_with_underscores_action: REJECT_REQUEST }
- name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
http_service:
server_uri: { uri: "http://antibot:8080", cluster: antibot, timeout: 0.5s }
path_prefix: /_antibot/auth
authorization_request:
allowed_headers:
patterns: [{ safe_regex: { regex: ".*" } }]
# Added, overwriting any a visitor sent.
headers_to_add:
- { key: Antibot-Forward-Secret, value: "<ANTIBOT_FORWARD_SECRET>" }
- { key: Antibot-Client-Ip, value: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%" }
- { key: Antibot-Original-Host, value: "%REQ(:AUTHORITY)%" }
authorization_response:
allowed_upstream_headers:
patterns: [{ prefix: antibot-assess- }]
failure_mode_allow: true
With ANTIBOT_FORWARD_PROXY=envoy, the service reads the request from what
Envoy asks with: the visitor's own method, the visitor's path after the
prefix, and the host the configuration adds. A visitor's own X-Forwarded-Uri
or X-Original-URI changes nothing. The service also reads the body when
with_request_body is set.
Envoy passes headers with underscores by default. The service refuses a
request carrying a verdict header spelled that way, but set
headers_with_underscores_action: REJECT_REQUEST (or DROP_HEADER) in the
connection manager's common_http_protocol_options all the same: a CGI or
FastCGI application behind it reads antibot_assess_action as the verdict.
Kubernetes
The manifests below follow the configurations above, which were run; they have not been applied to a cluster.
The service is a Deployment of the container above, beside the ingress controller, reachable only from it:
apiVersion: apps/v1
kind: Deployment
metadata: { name: antibot, namespace: antibot }
spec:
replicas: 2
selector: { matchLabels: { app: antibot } }
template:
metadata: { labels: { app: antibot } }
spec:
containers:
- name: forward-auth
image: registry.example.com/antibot-forward-auth:1.0.0
ports: [{ containerPort: 8080 }]
envFrom: [{ secretRef: { name: antibot } }] # ANTIBOT_* settings, ANTIBOT_FORWARD_SECRET among them
env: [{ name: ANTIBOT_FORWARD_PROXY, value: traefik }]
readinessProbe: { httpGet: { path: /_antibot/health, port: 8080 } }
livenessProbe: { httpGet: { path: /_antibot/health, port: 8080 } }
resources: { requests: { cpu: 50m, memory: 64Mi } }
---
apiVersion: v1
kind: Service
metadata: { name: antibot, namespace: antibot }
spec:
selector: { app: antibot }
ports: [{ port: 8080 }]
---
# Only the ingress controller may ask it.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: antibot, namespace: antibot }
spec:
podSelector: { matchLabels: { app: antibot } }
ingress:
- from: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: traefik } } }]
/_antibot/health answers 503 until the secret and the proxy are set, so a
misconfigured pod never takes traffic.
Traefik. The file configuration above, as resources: the same middlewares, chained in the same order on an IngressRoute.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata: { name: antibot, namespace: shop }
spec:
forwardAuth:
address: http://antibot.antibot.svc:8080/_antibot/auth
authResponseHeaders: [Antibot-Assess-Action, Antibot-Assess-Score, Antibot-Assess-Reasons, Antibot-Assess-Id, Antibot-Assess-Would-Act, Antibot-Assess-Would-Reason]
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata: { name: antibot-secret, namespace: shop }
spec:
headers: { customRequestHeaders: { Antibot-Forward-Secret: "<ANTIBOT_FORWARD_SECRET>" } }
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata: { name: antibot-secret-off, namespace: shop }
spec:
headers: { customRequestHeaders: { Antibot-Forward-Secret: "" } }
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata: { name: shop, namespace: shop }
spec:
entryPoints: [websecure]
routes:
- match: PathPrefix(`/_ab/`)
priority: 1000
middlewares: [{ name: antibot-secret }]
services: [{ name: antibot, namespace: antibot, port: 8080 }]
- match: PathPrefix(`/`)
middlewares: [{ name: antibot-secret }, { name: antibot }, { name: antibot-secret-off }]
services: [{ name: shop, port: 80 }]
A route in another namespace than its middleware or service needs Traefik's
allowCrossNamespace (or the service in the same namespace). Add the
forwardBody route for your configured forms as in the file configuration.
The address the service believes is the one Traefik appends, so Traefik must
see the visitor's: give its Service externalTrafficPolicy: Local, or have
the load balancer speak PROXY protocol to it. Otherwise every visitor is a
node's address, and every per-address count is one count.
Behind a layer-7 load balancer, prefer PROXY protocol from the balancer to
Traefik, with ANTIBOT_FORWARD_TRUSTED_HOPS left at 0. Traefik's ForwardAuth
sends only the address Traefik itself saw — the balancer's — unless the
middleware sets trustForwardHeader: true; then the entry point must also
list the balancer in forwardedHeaders.trustedIPs, or a client's own
X-Forwarded-For is passed along with it. Only with both is
ANTIBOT_FORWARD_TRUSTED_HOPS=1 right. (Measured with Traefik 3.5: without
them, hops of 1 made every visitor the balancer.)
Gateway API and Istio. Envoy-based gateways take the service as an
external authorization (HTTP) service: Envoy Gateway through a
SecurityPolicy with extAuth.http, Istio through an envoyExtAuthzHttp
extension provider and a CUSTOM AuthorizationPolicy. Give them what the
Envoy configuration above gives: the path prefix /_antibot/auth, the
Antibot-Forward-Secret, Antibot-Client-Ip and Antibot-Original-Host
headers added on the way, the antibot-assess- headers allowed back
upstream, and ANTIBOT_FORWARD_PROXY=envoy. Where a gateway cannot add
Antibot-Client-Ip to the authorization request, it cannot tell the service
who the visitor is: use a server SDK's middleware, or an
edge in front, instead.
ingress-nginx was retired in March 2026 and gets no further fixes; its
auth-url annotation does not carry the Antibot-Client-Ip the nginx
configuration above sets without auth-snippet, which recent releases turn
off. Move to one of the above.
What it cannot do
The service sits beside the request, not in its path. Two things follow:
- It never sees the site's answer. It cannot place the agent on pages
(
ANTIBOT_AGENT_INJECTis ignored here). Load the agent from your pages yourself. It also cannot read a sign-in's outcome from the answer: report outcomes from your application with a server SDK. - It sees a form's body only when the proxy forwards it. Traefik's
forwardBodydoes, and so does Envoy'swith_request_body. Without the body, a configured sign-in is still assessed, just without its account. To read the account, put a server SDK's middleware (Node, Python, Go, Java, PHP or .NET) on the sign-in route instead.
If the service fails while deciding, the request goes through, as at every
edge. A service that cannot be reached at all is the proxy's to handle:
nginx, Traefik and Caddy refuse the request with an error of their own, and
Envoy lets it through with failure_mode_allow. Run more than one instance,
beside the proxy.