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-hansestackOption 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
| Directive | Default | Description |
|---|---|---|
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. |
mode | enrich_request | Execution mode: observe, enrich_request, enrich_response or block. |
password_field | password | The key in the JSON or form-data body holding the password. |
header_leaked | X-Hansestack-Leaked | Header containing the result (true/false). |
header_count | X-Hansestack-Leak-Count | Header containing the number of breach occurrences. |
block_status | 401 | HTTP status code returned when mode=block triggers. |
timeout | 500ms | Maximum wait time for the API call (fail-open takes over after this). |
circuit_breaker_threshold | disabled (0) | Number of consecutive failures before the breaker trips. |
circuit_breaker_cooldown | 30s | How long the circuit stays open, locally skipping checks instantly. |
max_idle_conns | 200 | Caps 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:
observe(Zero-Risk Observability): Asynchronous. Runs the leak check exactly likeenrich_response, but never injectsheader_leaked/header_countinto 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 metrichansestack_leakcheck_responses_total(see Metrics below). Chooseobservewhen 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.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
enrich_request: Synchronous. Waits for the API (maxtimeout) and injects the leak headers into the incoming request. Your backend can read these headers and decide how to act.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.
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 theContent-Typeheader before reading a single byte of the body. Onlyapplication/jsonandapplication/x-www-form-urlencodedare ever inspected — any other content type, most notablymultipart/form-data, is passed through completely untouched.r.Bodyis 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-goclient'sCheckPasswordmethod, 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 thoughmultipart/form-datais 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):
| Metric | Type | Description |
|---|---|---|
hansestack_leakcheck_checks_total{result} | Counter | Total passwords checked, labeled result="leaked" or result="not_leaked". |
hansestack_leakcheck_check_outcomes_total{outcome} | Counter | The 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_total | Counter | Total 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} | Counter | Indicator-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. |
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.