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
internallocation,/_agentgate/check, callsGET /v1/gateway/checkwithout the body, and overwrites everyX-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:
401for a challenge,403for a block,429forrate_limited,402(withCrawler-Price) forpayment_required, and503when AgentGate answered503, 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
# 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 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:
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.