nginx

Protect any app behind nginx with auth_request and /v1/gateway/check, using a configuration tested against a real nginx.

nginx's auth_request module sends a subrequest to AgentGate before each request to a protected location and forwards the request only when AgentGate answers 204. The configuration below is the repository's docs/nginx/agentgate.conf; the test suite runs this exact file against a real nginx (go test -tags nginx -run TestNginxGateway .).

What it does

  • An internal location, /_agentgate/check, calls GET /v1/gateway/check without the body, and overwrites every X-Original-* header and the credential, so a client cannot inject them.
  • The backend secret comes from a root-only include file, never from the main configuration.
  • Before the request reaches your app, client-supplied X-AgentGate-* headers are stripped and AgentGate's own (X-AgentGate-Decision-ID, X-AgentGate-Agent, X-AgentGate-Would-Decision, X-AgentGate-Digest-Check) are set.
  • Denials become JSON with a safe, lower-case reason only: 401 for a challenge, 403 for a block, 429 for rate_limited, 402 (with Crawler-Price) for payment_required, and 503 when AgentGate answered 503, timed out or was unreachable (fail closed).
  • Pay-per-crawl headers and signed agent receipts (Crawler-Charged, AgentGate-Receipt) are passed to the client.

1. Store the secret

/etc/nginx/agentgate/credential.confnginx
# owner root, mode 0600
set $agentgate_authorization "Bearer ags_…";   # your backend secret
set $agentgate_site_key "site_…";

After rotating the secret, write the new value and run nginx -s reload before the old one stops working.

2. Add the configuration

Include this in the http {} context. Change the two upstream addresses, server_name and your TLS listener. nginx -V must list --with-http_auth_request_module (most builds do).

agentgate.confnginx
# AgentGate gateway adapter for nginx (auth_request).
#
# Include this file in the http {} context. It protects the application at
# 127.0.0.1:3000 with the AgentGate service at 127.0.0.1:18080. Every request
# to a protected location is checked with GET /v1/gateway/check before it is
# forwarded; the site's route configuration in AgentGate decides which
# requests need a browser receipt, a clearance, or nothing (machine routes).
#
# This exact file is exercised against a real nginx by
#   go test -tags nginx -run TestNginxGateway -v .
# (see examples/nginx-gateway/README.md). The test substitutes only the
# listen address, the two upstream addresses and the credential include path.
#
# Requirements: ngx_http_auth_request_module (in most builds; check
# `nginx -V` for --with-http_auth_request_module).

# Only lower-case reason codes ever reach a client; anything else (including
# an empty value when AgentGate was unreachable) becomes "unavailable".
map $agentgate_reason $agentgate_safe_reason {
    default           "unavailable";
    "~^[a-z_]{1,40}$" $agentgate_reason;
}

upstream agentgate {
    server 127.0.0.1:18080;   # AgentGate; keep it on a private address
    keepalive 16;
}

upstream app_origin {
    server 127.0.0.1:3000;    # your application
    keepalive 16;
}

server {
    listen 80;                # add your TLS listener and certificates here
    server_name app.example.com;

    # The site's backend credential, from a root-only file (mode 0600):
    #   set $agentgate_authorization "Bearer ags_<id>_<secret>";
    #   set $agentgate_site_key "site_<key>";
    # After a rotation, write the new credential and `nginx -s reload`.
    include /etc/nginx/agentgate/credential.conf;

    # --- the authorization subrequest (never reachable from outside) ---
    location = /_agentgate/check {
        internal;
        proxy_pass http://agentgate/v1/gateway/check;
        proxy_method GET;                  # the subrequest would inherit POST, PUT, ...
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_pass_request_body off;       # auth_request never sends the body
        proxy_set_header Content-Length "";
        proxy_set_header Host agentgate.internal;

        # Our credential replaces anything the client sent.
        proxy_set_header Authorization $agentgate_authorization;
        proxy_set_header X-AgentGate-Site-Key $agentgate_site_key;

        # Trusted metadata: overwrite every X-Original-* header AgentGate
        # reads, including the one this adapter does not supply, so a client
        # cannot inject them.
        proxy_set_header X-Original-Method $request_method;
        proxy_set_header X-Original-Host $http_host;
        proxy_set_header X-Original-URI $request_uri;
        proxy_set_header X-Original-IP $remote_addr;
        proxy_set_header X-Original-Proto $scheme;
        proxy_set_header X-Original-Body-Digest "";   # nginx never sees the body

        # Bounded decision time; a timeout becomes the 503 below.
        proxy_connect_timeout 1s;
        proxy_send_timeout 2s;
        proxy_read_timeout 2s;
    }

    # --- a protected application location ---
    location / {
        auth_request /_agentgate/check;
        auth_request_set $agentgate_reason      $upstream_http_x_agentgate_reason;
        auth_request_set $agentgate_decision_id $upstream_http_x_agentgate_decision_id;
        auth_request_set $agentgate_agent       $upstream_http_x_agentgate_agent;
        auth_request_set $agentgate_would       $upstream_http_x_agentgate_would_decision;
        auth_request_set $agentgate_digest      $upstream_http_x_agentgate_digest_check;
        # Agent-native headers (per-agent pricing, signed receipts) for the client.
        auth_request_set $agentgate_price       $upstream_http_crawler_price;
        auth_request_set $agentgate_price_error $upstream_http_crawler_error;
        auth_request_set $agentgate_charged     $upstream_http_crawler_charged;
        auth_request_set $agentgate_receipt     $upstream_http_agentgate_receipt;
        add_header Crawler-Charged $agentgate_charged always;
        add_header AgentGate-Receipt $agentgate_receipt always;

        # auth_request: 401/403 deny, anything else is an error (500).
        error_page 401 = @agentgate_challenge;
        error_page 403 = @agentgate_block;
        error_page 500 = @agentgate_unavailable;

        # Strip client-supplied AgentGate headers before the origin, then add
        # the ones AgentGate set. (A receipt was consumed by the check.)
        proxy_set_header X-AgentGate-Token "";
        proxy_set_header X-AgentGate-Clearance "";
        proxy_set_header X-AgentGate-Site-Key "";
        proxy_set_header X-AgentGate-Decision "";
        proxy_set_header X-AgentGate-Reason "";
        proxy_set_header X-AgentGate-Decision-ID $agentgate_decision_id;
        proxy_set_header X-AgentGate-Agent $agentgate_agent;
        proxy_set_header X-AgentGate-Would-Decision $agentgate_would;
        proxy_set_header X-AgentGate-Digest-Check $agentgate_digest;
        proxy_set_header X-Original-Body-Digest "";

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_pass http://app_origin;
    }

    # --- denials: JSON bodies with a safe reason code only ---
    location @agentgate_challenge {
        default_type application/json;
        add_header X-AgentGate-Reason $agentgate_safe_reason always;
        add_header Cache-Control "no-store" always;
        return 401 '{"error":"agentgate_challenge","reason":"$agentgate_safe_reason"}\n';
    }

    location @agentgate_block {
        default_type application/json;
        add_header X-AgentGate-Reason $agentgate_safe_reason always;
        add_header Cache-Control "no-store" always;
        add_header Crawler-Price $agentgate_price always;
        add_header Crawler-Error $agentgate_price_error always;
        add_header AgentGate-Receipt $agentgate_receipt always;
        if ($agentgate_safe_reason = "rate_limited") {
            return 429 '{"error":"agentgate_rate_limited","reason":"rate_limited"}\n';
        }
        if ($agentgate_safe_reason = "payment_required") {
            return 402 '{"error":"agentgate_payment_required","reason":"payment_required"}\n';
        }
        return 403 '{"error":"agentgate_blocked","reason":"$agentgate_safe_reason"}\n';
    }

    # AgentGate answered 503 (enforce mode, dependency failure), timed out or
    # was unreachable. The request is not forwarded.
    location @agentgate_unavailable {
        default_type application/json;
        add_header X-AgentGate-Reason service_unavailable always;
        add_header Retry-After 5 always;
        add_header Cache-Control "no-store" always;
        return 503 '{"error":"agentgate_unavailable","reason":"service_unavailable"}\n';
    }
}

3. Send tokens from the page

Browser routes need the token as a header, because auth_request never sees the body:

JavaScript
const { token } = await AgentGate.execute({ action: "signup" });
await fetch("/api/signup", { method: "POST", headers: { "X-AgentGate-Token": token }, body });

For classic form posts with a hidden agentgate_token field, validate the token in your backend with /v1/siteverify instead.

Timeouts and failure

The subrequest is bounded (1 s connect, 2 s send and read). A timeout, an unreachable AgentGate or a 503 answer becomes the 503 JSON response above: the request is not forwarded. If you prefer to fail open for a monitor-mode site, change @agentgate_unavailable to proxy to your app instead, and alert on it.

Warning

Keep AgentGate itself on a private address. /v1/gateway/check trusts the X-Original-* headers of any caller that presents the backend secret; the secret must stay in nginx.

Try it locally

examples/nginx-gateway/run-test.sh in the repository runs this file in a temporary nginx prefix on free ports, with AgentGate and a recording origin, and checks machine routes, missing, forged, valid and reused tokens, forged X-Original-* headers and the internal location. It never touches the system nginx configuration.

View as Markdown