Skip to content
Open Beta Hansestack is completely free during the current phase. 🐙 Submit feedback on GitHub

Caddy v2 Plugin (Zero-Code)

The official Caddy v2 plugin for Hansestack enables Decoupled Security: You can attach the k-Anonymity leak check directly to your reverse proxy without touching a single line of code in your backend.

The plugin intercepts requests, securely checks contained passwords, and enriches the request or response with the outcome. Your backend never has to import a client library or call an API.

Resources & Hands-on

  • 🐙 Source Code: Find the source code, releases, and issue tracker in the caddy-hansestack repository.
  • 👾 Try the Sandbox: Want to see the plugin in action? Clone our Demo Repo, type docker-compose up, and watch the provided Grafana dashboard to see exactly how the leak check (and the fail-open fallback) performs under load.

Installation

There are two ways to deploy the plugin in your infrastructure.

Option A: Build with xcaddy (Recommended)

If you build your own Caddy binaries, use xcaddy:

xcaddy build --with github.com/hansestack/caddy-hansestack

Option B: Pre-built Docker Image

We provide a pre-built Docker image based on the official Caddy image:

# docker-compose.yml
services:
  caddy:
    image: ghcr.io/hansestack/caddy-hansestack:latest
    ports:
      - "80:80"
      - "443:443"

(Tip: In production, pin a specific version instead of :latest, e.g. :v1.0.0)

Configuration (Caddyfile)

Configuration is handled natively via the Caddyfile. The plugin registers itself to always run before the reverse_proxy directive in Caddy's execution order.

:443 {
    hansestack /login* leakcheck {
        # Optional: route traffic to a self-hosted/on-premise deployment
        # (e.g. a local sidecar container) instead of the public SaaS API.
        # Leave unset to use the public Hansestack SaaS endpoint.
        endpoint "http://localhost:8081"

        # Required for the public SaaS API. Optional if endpoint above points
        # at a trusted on-premise/sidecar deployment running with auth disabled.
        api_key {$HANSESTACK_API_KEY}
        mode enrich_response
        password_field "password"
        
        # Optional circuit breaker tuning for high resilience
        timeout 500ms
        circuit_breaker_threshold 5
        circuit_breaker_cooldown 30s
        max_idle_conns 200
    }

    reverse_proxy backend:8080
}

Configuration Directives

DirectiveDefaultDescription
endpoint(public SaaS API)Overrides the Hansestack API base URL. Point it at a self-hosted/on-premise deployment — e.g. a sidecar container in the same Kubernetes Pod — for data sovereignty or to avoid egress bandwidth limits. Leave unset to use the public SaaS endpoint.
api_key(Required for public SaaS)Your Hansestack API key. (Use {$ENV_VAR} to inject via env variables). Optional when endpoint points at a trusted self-hosted/on-premise deployment (e.g. a sidecar) running with authentication disabled — hansestack-go omits the API key header entirely in that case rather than sending it empty.
modeenrich_requestExecution mode: observe, enrich_request, enrich_response or block.
password_fieldpasswordThe key in the JSON or form-data body holding the password.
header_leakedX-Hansestack-LeakedHeader containing the result (true/false).
header_countX-Hansestack-Leak-CountHeader containing the number of breach occurrences.
block_status401HTTP status code returned when mode=block triggers.
timeout500msMaximum wait time for the API call (fail-open takes over after this).
circuit_breaker_thresholddisabled (0)Number of consecutive failures before the breaker trips.
circuit_breaker_cooldown30sHow long the circuit stays open, locally skipping checks instantly.
max_idle_conns200Caps the underlying HTTP transport's idle (keep-alive) connection pool — applied to both MaxIdleConns and MaxIdleConnsPerHost, since there is normally only one upstream (the SaaS API or a sidecar). Prevents TCP socket exhaustion under massive load spikes.

Operation Modes

Depending on your auth flow architecture, the plugin offers four modes:

  1. observe (Zero-Risk Observability): Asynchronous. Runs the leak check exactly like enrich_response, but never injects header_leaked/header_count into the request or the response — nothing a client or your backend can observe or act on changes at all. This mode exists purely to populate the Indicator-of-Compromise correlation metric hansestack_leakcheck_responses_total (see Metrics below). Choose observe when you first want to see whether leaked passwords are producing successful logins, without changing a single byte of request or response — ideal for an initial production rollout before committing to a more active mode.
  2. enrich_response (Recommended): Asynchronous. The leak check runs concurrently with your backend processing. It adds zero latency to your login. The leak headers are injected into the outgoing response back to the client.
    sequenceDiagram
    participant C as Client
    participant P as Caddy (Plugin)
    participant B as Your Backend
    participant H as Hansestack API

    C->>P: POST /login (Credentials)
    
    par Asynchronous Leak-Check
        P->>H: Send Hash Prefix
        H-->>P: Suffix List (No Leak)
    and Normal Login Flow
        P->>B: POST /login (Original Body)
        Note over B: Backend verifies Login
        B-->>P: HTTP 201 (Session Token)
    end
    
    P-->>C: HTTP 201 + X-Hansestack-Leaked Header
  
  1. enrich_request: Synchronous. Waits for the API (max timeout) and injects the leak headers into the incoming request. Your backend can read these headers and decide how to act.
  2. block: Synchronous. The only mode capable of rejecting a request. Only if a leak is affirmatively confirmed will Caddy short-circuit the request and return e.g. HTTP 401. Your backend is never invoked in this scenario.
Fail-Open by Design
In all modes: In case of API timeouts, rate limits, or network errors, the request is always passed through unmodified. A disruption in the leak check service will never lock your legitimate users out of their accounts.

Security & Performance

  • multipart/form-data (file uploads) is completely ignored. The plugin checks the Content-Type header before reading a single byte of the body. Only application/json and application/x-www-form-urlencoded are ever inspected — any other content type, most notably multipart/form-data, is passed through completely untouched. r.Body is never wrapped, read, or replaced in that case, so large uploads are never buffered into memory just because this plugin is installed in front of the endpoint.
  • For the two content types that are inspected, the body is read through an io.LimitReader (1 MiB cap) to bound memory usage against oversized or malicious payloads. Bodies over the limit are left completely unparsed (no password check runs) but are still forwarded to your backend byte-for-byte.
  • The plaintext password is only ever handed to the official hansestack-go client's CheckPassword method, in-process, for the duration of a single k-anonymity lookup. It is never logged, cached, or written to disk by this plugin.
  • Additionally use path scoping (the /login* matcher syntax above) to restrict the leak check to your actual auth endpoints — defense in depth, even though multipart/form-data is already skipped automatically.

Metrics (Prometheus)

If you enable Caddy's global metrics option, this plugin exposes its own Prometheus counters on Caddy's built-in /metrics endpoint, under a dedicated hansestack_leakcheck_* namespace (distinct from Caddy's own caddy_http_* metrics, since these describe a Hansestack API outcome, not an HTTP server statistic):

MetricTypeDescription
hansestack_leakcheck_checks_total{result}CounterTotal passwords checked, labeled result="leaked" or result="not_leaked".
hansestack_leakcheck_check_outcomes_total{outcome}CounterThe reasoning behind every check, labeled outcome="checked", "skipped_timeout", "skipped_rate_limited", "skipped_circuit_open", "skipped_error", or "skipped_canceled". Under fail-open, a skipped check and a genuine "not leaked" both count as result="not_leaked" above — this metric is what tells them apart.
hansestack_leakcheck_check_errors_totalCounterTotal checks that fell back to fail-open because the API client errored. In the default configuration this should stay at zero; a nonzero rate signals a misconfiguration (e.g. an invalid API key) worth investigating, even though no end user was ever affected.
hansestack_leakcheck_responses_total{result, status_class}CounterIndicator-of-Compromise correlation counter. Pairs the leak-check result with the backend's own final HTTP response status class. result is "leaked", "not_leaked", or "skipped" (any fail-open skip reason, collapsed into one bucket). status_class is bucketed to "2xx"/"3xx"/"4xx"/"5xx"/"unknown" (never a raw status code) to keep cardinality bounded regardless of what the backend returns. Recorded in every mode, including observe, and even if the backend panics.
🚨 The single most important series on your entire dashboard: result="leaked", status_class="2xx"
hansestack_leakcheck_responses_total{result="leaked", status_class="2xx"} is the single most actionable series this plugin exposes: it means a request carrying a password confirmed to be in a known breach nonetheless reached the backend and got a successful response — i.e. a leaked password was used to log in successfully. This is a confirmed Account Takeover (ATO). This metric is recorded in every mode, not just block, since observe/enrich_request/enrich_response all forward the request regardless of the check result. It's recorded via a defer in every mode, so an observation is never silently dropped — not on an early return, not if the backend panics (recorded as status_class="unknown" in that case).

Example Queries (PromQL)

Leak rate over the last 5 minutes:

sum(rate(hansestack_leakcheck_checks_total{result="leaked"}[5m]))
/
sum(rate(hansestack_leakcheck_checks_total[5m]))

Share of checks that were actually answered by the API (as opposed to skipped under fail-open):

sum(rate(hansestack_leakcheck_check_outcomes_total{outcome="checked"}[5m]))
/
sum(rate(hansestack_leakcheck_check_outcomes_total[5m]))

A sustained drop below 1.0 here — especially with a rising outcome="skipped_circuit_open" or "skipped_timeout" rate — means the Leak-Check API is degraded or unreachable; end users are unaffected (every mode except block still passes requests through), but the leak check itself isn't running.

Indicator-of-Compromise: leaked passwords that nonetheless produced a successful login, over the last hour:

sum(increase(hansestack_leakcheck_responses_total{result="leaked", status_class="2xx"}[1h]))

A nonzero, sustained value here is worth alerting on regardless of mode: it means a request carrying a password confirmed to be in a known breach was forwarded to (or, in enrich_request/enrich_response, processed by) your backend and got back a 2xx response — i.e. someone successfully authenticated with a known-leaked credential.