Forward auth for your server

Block bots, VPNs and Tor in nginx, Apache and HAProxy

One container next to your server checks every request for bots, Tor, VPNs and proxies. It starts in monitor mode and blocks nothing until you switch.

Free, MIT licenseDocker, amd64 and arm64

A Tor visitor reaches nginx, which asks the IPScanner gate, gets a 403 and shows the gate's block page. A person gets a 200 and goes on to the app with verdict headers. In proxy mode, for Apache and HAProxy, the gate sits between the server and the app, which stays as the backup.
Enforce

Traffic classes

What it can block

Each class reaches your app as X-IPScanner-Class. Allow, flag or block it per site in the dashboard.

  • Malicious automation

    malicious_automation

  • AI agent

    ai_agent

  • Tor

    tor

  • VPN

    vpn

  • Proxy

    proxy

  • Hosting

    hosting

  • Private relay

    relay

  • Verified bot

    verified_bot

  • Human

    human

Setup

Live in four steps

  1. 1

    Create an API key

    The free plan is enough to start.

    IPSCANNER_API_KEY

    pk_live_••••••••3f9a

    Create a free API key
  2. 2

    Run the container

    On the same Docker network as your proxy. Do not publish its port.

    docker-compose.yml
    services:
      ipscanner:
        image: ghcr.io/ipscanner/forward-auth:0.1
        restart: unless-stopped
        environment:
          IPSCANNER_API_KEY: ${IPSCANNER_API_KEY}
          SITE_ID: site_...
  3. 3

    Add a site and set SITE_ID

    The dashboard then holds the site's policy and shows its traffic.

    SITE_ID

    site_4fQ8nZ2kLm7xR1vT9cBw

    Add a site
  4. 4

    Add the snippet to your server

    Pick your server below. Each snippet is tested end to end.

Server config

Snippets for your server

Copy the one for your setup. Traefik and Caddy also work through the same container.

Goes in your server block. Start the gate first: nginx resolves ipscanner at startup.

Gate down: requests pass after 1 s

nginx.conf
location / {
    auth_request /_ipscanner;
    auth_request_set $ipscanner_status     $upstream_http_x_ipscanner_status;
    auth_request_set $ipscanner_class      $upstream_http_x_ipscanner_class;
    auth_request_set $ipscanner_action     $upstream_http_x_ipscanner_action;
    auth_request_set $ipscanner_network    $upstream_http_x_ipscanner_network_class;
    auth_request_set $ipscanner_anonymized $upstream_http_x_ipscanner_anonymized;
    auth_request_set $ipscanner_risk       $upstream_http_x_ipscanner_risk;
    auth_request_set $ipscanner_country    $upstream_http_x_ipscanner_country;
    auth_request_set $ipscanner_site       $upstream_http_x_ipscanner_site;
    auth_request_set $ipscanner_request_id $upstream_http_x_ipscanner_request_id;
    error_page 403 = @ipscanner_blocked;

    proxy_set_header X-IPScanner-Status        $ipscanner_status;
    proxy_set_header X-IPScanner-Class         $ipscanner_class;
    proxy_set_header X-IPScanner-Action        $ipscanner_action;
    proxy_set_header X-IPScanner-Network-Class $ipscanner_network;
    proxy_set_header X-IPScanner-Anonymized    $ipscanner_anonymized;
    proxy_set_header X-IPScanner-Risk          $ipscanner_risk;
    proxy_set_header X-IPScanner-Country       $ipscanner_country;
    proxy_set_header X-IPScanner-Site          $ipscanner_site;
    proxy_set_header Host                      $host;
    proxy_set_header X-Forwarded-For           $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto         $scheme;
    proxy_pass http://app:80;
}

location = /_ipscanner {
    internal;
    proxy_pass http://ipscanner:8080/check;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Original-URI $request_uri;
    proxy_set_header X-Original-Method $request_method;
    proxy_connect_timeout 1s;
    proxy_read_timeout 5s;
    error_page 500 502 503 504 = @ipscanner_down;
}

location @ipscanner_down {
    return 204;
}

location @ipscanner_blocked {
    internal;
    proxy_method GET;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header X-IPScanner-Request-Id $ipscanner_request_id;
    rewrite ^ /blocked break;
    proxy_pass http://ipscanner:8080;
}

Settings

Environment variables

Set them on the container. CACHE_TTL and POLICY_TTL also take values like 10m.

Variable
IPSCANNER_API_KEYYour API key. Without it every request passesDefault:Empty
IPSCANNER_API_KEY_FILEReads the key from a file, for Docker secretsDefault:Empty
SITE_IDLinks the gate to a dashboard site and its policyDefault:Empty
MODEmonitor or enforce. With SITE_ID, the dashboard decides unless this is monitorDefault:monitor
BLOCK_CLASSESClasses blocked in enforce mode when there is no SITE_IDDefault:malicious_automation
TRUSTED_PROXIESProxies whose X-Forwarded-For is believedDefault:private
IP_HEADERSHeaders read for the visitor IP, in orderDefault:X-Forwarded-For
TIMEOUT_MSBudget per check, then the request passesDefault:1500
CACHE_TTLSeconds a verdict is reused per visitorDefault:600
CACHE_SIZEVerdicts kept in memoryDefault:10000
POLICY_TTLSeconds a site policy is reusedDefault:30
SKIP_PATHSPath regex that skips the checkDefault:Static assets
UPSTREAM_URLTurns on proxy mode and forwards allowed requests hereDefault:Empty
LISTENListen addressDefault::8080
HEALTH_PATHHealth endpointDefault:/healthz
BLOCK_MESSAGEText of the 403 pageDefault:Blocked by IPScanner edge guard.
IPSCANNER_API_URLAPI base URLDefault:https://ipscanner.io
DEBUGLogs one line per decisionDefault:false

Container

A small image with a health check

GET /healthz always answers 200 and shows what the gate is doing.

  • Verified search and AI crawlers always pass.
  • If IPScanner is slow or down, requests go through.
  • Your API key never shows up in the logs.
GET /healthz
{
  "status": "ok",
  "version": "0.1.0",
  "mode": "forward_auth",
  "enabled": true,
  "site": "site_4fQ8nZ2kLm7xR1vT9cBw",
  "backoff": false,
  "policyMode": "monitor",
  "policyVersion": 3,
  "cachedVerdicts": 128
}

ghcr.io/ipscanner/forward-auth:0.1

  • linux/amd64
  • linux/arm64
  • Under 4 MB
  • Non-root, no shell

Questions

nginx, Apache and HAProxy FAQ

No. It is a per-request check of the visitor's IP and automation signals, not a rule-based WAF that inspects payloads for SQL injection or XSS. If you need one, keep your WAF next to it.

Start in monitor mode

Run the gate, read a week of decisions, then block.