← All docs

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_action and Antibot-Assess-Action are the same variable, HTTP_ANTIBOT_ASSESS_ACTION. Behind php_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, and request_header -Antibot_* removes them before anything is asked. nginx drops headers with underscores unless underscores_in_headers is on; leave it off.
  • Keep everything inside route. Caddy's default directive order runs request_header after forward_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_INJECT is 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 forwardBody does, and so does Envoy's with_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.