# 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

```nginx title="/etc/nginx/agentgate/credential.conf"
# 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).

```nginx title="agentgate.conf"
# 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:

```js
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`](/docs/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.
