A tiny reverse proxy that holds the door — put it in front of any app to gate access behind a single shared password. No users, no database, no OAuth. Just one password and a login page.
Features
- Single shared password — no user accounts, no database
- Clean dark-themed login page (or bring your own with Jinja2 templates)
- Runs as a Docker sidecar in front of any web app
- HMAC-SHA256 signed session cookies
- Streaming reverse proxy (handles large proxied uploads/downloads without buffering; login forms are capped at 16 KiB)
- WebSocket proxying
- Constant-time password comparison
- Brute-force protection: per-IP rate limiting (5 attempts / 60s), escalating lockouts after repeated failures, and delayed responses to failed logins
- Public paths that skip the gate entirely, for health checks, webhooks, and OAuth endpoints
- Tells the upstream how each request was authenticated via
X-Hodor-Auth - Structured tracing output (compact or JSON)
- Health check endpoint for container orchestrators
- Graceful shutdown on SIGTERM
- Layered config: defaults →
hodor.toml→ environment variables - Built with Rust, runs from a
scratchimage (~5MB)
Quick Start
# docker-compose.yml services: gate: image: ghcr.io/michidk/hodor:latest ports: - "8080:8080" environment: PASSWORD: "${PASSWORD:?set PASSWORD}" # the login password UPSTREAM: "http://app:80" SECRET: "${SECRET:?set SECRET}" # signs session cookies depends_on: - app app: image: traefik/whoami
export PASSWORD='choose-a-strong-password' export SECRET="$(openssl rand -hex 32)" docker compose up
Compose refuses to start until both values are set. Never reuse example values for a deployment that is reachable by others.
Open http://localhost:8080 — you'll see the login page. Enter the password, and you're proxied through to the app.
Example login page with TITLE=VibePod Preview:
Configuration
Hodor uses layered configuration. Each layer overrides the previous:
- Defaults — sensible built-in values
hodor.toml— optional config file in the working directory- Environment variables — override everything (uppercase, e.g.
PASSWORD)
Options
| Key | Env var | Required | Default | Description |
|---|---|---|---|---|
password |
PASSWORD |
yes | The shared password; must not be empty | |
upstream |
UPSTREAM |
yes | Backend URL to proxy to (e.g. http://app:3000); must not be empty. Upstream connections are plain HTTP only — https:// upstreams are not supported, so terminate TLS before hodor's upstream connection |
|
secret |
SECRET |
no | random | Non-empty cookie signing key. Set this to persist sessions across restarts |
listen |
LISTEN |
no | :8080 |
Listen address |
title |
TITLE |
no | Password Required |
Login page heading |
custom_css |
CUSTOM_CSS |
no | Extra CSS injected after the built-in styles on the login and error pages | |
disable_default_css |
DISABLE_DEFAULT_CSS |
no | false |
Set true to drop the built-in styles entirely (style from scratch with custom_css) |
template |
TEMPLATE |
no | built-in | Path to a custom HTML login page template |
error_template |
ERROR_TEMPLATE |
no | built-in | Path to a custom HTML error page template |
session_ttl |
SESSION_TTL |
no | 86400 |
Positive session duration in seconds (default: 24h) |
upstream_connect_timeout |
UPSTREAM_CONNECT_TIMEOUT |
no | 10 |
Positive timeout in seconds for establishing an upstream connection |
upstream_header_timeout |
UPSTREAM_HEADER_TIMEOUT |
no | 30 |
Positive timeout in seconds for receiving upstream response headers; response bodies continue streaming without this deadline |
secure_cookie |
SECURE_COOKIE |
no | false |
Set true to add the Secure flag to cookies (requires HTTPS) |
trust_proxy |
TRUST_PROXY |
no | false |
Set true only when hodor runs directly behind a trusted reverse proxy, to accept its X-Forwarded-For client IP and preserve its X-Forwarded-Proto |
trusted_proxy_cidrs |
TRUSTED_PROXY_CIDRS |
no | Comma-separated proxy networks allowed to supply forwarding headers; requires TRUST_PROXY=true |
|
bypass_cidrs |
BYPASS_CIDRS |
no | Comma-separated client networks that bypass authentication after trusted-proxy client IP resolution | |
bypass_paths |
BYPASS_PATHS |
no | Comma-separated request paths served without authentication; exact by default, /prefix/* for a subtree |
|
preserve_host |
PRESERVE_HOST |
no | false |
Preserve the original request Host header instead of replacing it with the upstream authority |
cookie_domain |
COOKIE_DOMAIN |
no | Optional cookie domain, for example .preview.example.com, to share a login across subdomains |
|
log_format |
LOG_FORMAT |
no | compact |
Tracing output format: compact or json |
| — | RUST_LOG |
no | info |
Log level filter (e.g. debug, hodor=trace) |
Config File Example
# hodor.toml password = "<your-password>" # replace before use upstream = "http://app:3000" secret = "<output of openssl rand -hex 32>" # replace before use title = "Restricted Area" session_ttl = 3600 secure_cookie = true
Environment variables always win. Set PASSWORD=override and it takes precedence over password in the TOML file.
How It Works
Request → hodor
├─ /_gate/health → 200 ok (bypass auth)
├─ Path in BYPASS_PATHS? → Reverse proxy to UPSTREAM (X-Hodor-Auth: public)
├─ Has valid session cookie? → Reverse proxy to UPSTREAM (X-Hodor-Auth: password)
├─ Client IP in BYPASS_CIDRS? → Reverse proxy to UPSTREAM (X-Hodor-Auth: bypass)
└─ Otherwise? → Show login page
└─ POST /_gate/login
├─ Rate limited or locked out? → 429 (with Retry-After)
├─ Password correct? → Set cookie, redirect back
└─ Wrong? → Show login page with error (after a short delay)
Brute-Force Protection
Login attempts are guarded per client IP, entirely in memory:
- Rate limiting — at most 5 attempts per 60 seconds per IP.
- Escalating lockouts — after 10 consecutive failed attempts, the IP is locked out for 60 seconds; each further failure doubles the lockout, up to 1 hour.
- Failure delay — every failed attempt is answered after a 500ms delay to slow down online guessing.
- Body deadline — login form bodies must arrive within 10 seconds, or the request is answered with
408. Retry-After— rate-limited and locked-out responses return429with aRetry-Afterheader.
A successful login clears the IP's failure history. State is in-memory (capped at 10,000 tracked IPs), so it resets on restart.
By default hodor uses the TCP peer address as the client IP. If hodor runs behind another reverse proxy (a Kubernetes ingress, a load balancer), every client appears to come from the proxy's IP — one attacker could then lock out everyone. In that setup, set TRUST_PROXY=true so hodor uses the rightmost X-Forwarded-For entry (the address recorded by the proxy directly in front of it) instead. Set TRUSTED_PROXY_CIDRS as well to restrict which direct peers may supply forwarding headers. Leaving that list empty preserves the legacy behavior of trusting every direct peer when TRUST_PROXY=true.
BYPASS_CIDRS skips the password gate for matching resolved client addresses. This is useful for trusted private networks, such as a Tailscale tailnet (100.64.0.0/10). Configure it together with a trusted reverse proxy when Hodor receives traffic through an ingress; otherwise the proxy address, rather than the original client, is evaluated.
Public Paths
BYPASS_PATHS serves specific paths without authentication, which is useful for
endpoints a machine has to reach directly: webhooks, metrics, or the OAuth
discovery and token endpoints an API needs alongside a browser-facing app. It
replaces the common workaround of routing those paths around hodor with a second
ingress, so the upstream keeps receiving hodor's forwarded headers.
Entries match the request path only — never the query string — and are exact
unless they end in /*:
BYPASS_PATHS=/api/mcp,/oauth/token,/static/*/api/mcpmatches only/api/mcp, not/api/mcp/tools./static/*matches/static/and everything beneath it, but not/static.*is only accepted as a trailing/*segment;/a*band/a/*/bare rejected at startup, so a typo cannot silently widen access.
A path is matched only when it is already canonical. Requests containing . or
.. segments, duplicate slashes, or the percent-encoded forms %2e, %2f, and
%25 never match a public path and fall through to the password gate. Hodor
forwards the client's raw path, so this keeps its decision from disagreeing with
an upstream that normalises differently — /static/../secret cannot be smuggled
past the gate.
Public paths cannot shadow the reserved /_gate/* routes.
Forwarded Authentication
Every proxied request carries X-Hodor-Auth, telling the upstream how the
request cleared the gate:
| Value | Meaning |
|---|---|
password |
Presented a valid session cookie |
bypass |
Matched BYPASS_CIDRS |
public |
Matched BYPASS_PATHS; not authenticated |
Read this header instead of parsing hodor's session cookie. The cookie format is
an internal detail, and an app that re-implements the HMAC check needs a copy of
SECRET and breaks if the format changes. The header also lets an app tell a
password-authenticated visitor from one admitted by network trust, so it can
accept bypass for reads while demanding password for a destructive action.
Any client-supplied X-Hodor-* header is removed before hodor sets its own, so
the value cannot be forged — including on public paths, which are otherwise the
obvious place to try. As with any forwarded-authentication proxy, this holds only
while the upstream is reachable exclusively through hodor; bind it to loopback or
a private network so nothing can bypass the gate and set the header itself.
Reserved Paths
/_gate/login— login form submission (POST) / redirect to gate (GET)/_gate/logout— clears session cookie/_gate/health— returnsok(process liveness only; it does not check the upstream)
All other paths are proxied to the upstream.
Proxy Behavior
- Streams request and response bodies without buffering (safe for large files)
- Sets
X-Forwarded-ForandX-Forwarded-Protoheaders on proxied requests - Strips standard hop-by-hop headers and any additional headers named by
Connection - Replaces
Hostwith the upstream authority by default; setPRESERVE_HOST=truefor host-routed upstreams - Proxies WebSocket upgrades bidirectionally
Custom CSS
To restyle the built-in login and error pages without maintaining a full template, set custom_css. It is injected into a <style> tag after the built-in styles, so your rules take precedence:
environment: CUSTOM_CSS: | body { background: #1e3a5f; } .card { border-radius: 4px; } button { background: #ffb703; color: #000; }
The CSS is emitted verbatim (not escaped), so anything valid in a <style> block works.
To start from a blank slate instead of overriding the dark theme, set disable_default_css — the built-in styles are dropped entirely and only your CSS applies:
environment: DISABLE_DEFAULT_CSS: "true" CUSTOM_CSS: | body { font-family: system-ui; display: grid; place-items: center; min-height: 100vh; } .card { max-width: 400px; padding: 32px; border: 1px solid #ddd; border-radius: 12px; }
The built-in markup keeps the same structure and class names (.card, .error, .actions), so your stylesheet can target them directly. For different markup entirely, use a custom template instead (below).
Custom Login Page
Hodor ships with a built-in dark-themed login page. To use your own login page, set template to the path of an HTML file:
environment: TEMPLATE: /etc/hodor/login.html volumes: - ./my-login.html:/etc/hodor/login.html:ro
Templates use Jinja2 syntax (via minijinja). The following variables are available:
| Variable | Type | Description |
|---|---|---|
title |
string | The configured title (auto-escaped) |
show_error |
bool | true when the user entered a wrong password |
custom_css |
string | The configured custom_css — include it with {{ custom_css | safe }} to keep the override working in your template |
disable_default_css |
bool | true when disable_default_css is set — custom templates can use it to gate their own base styles |
Template Requirements
Use the built-in src/template.html as a starting point for custom designs.
- The form must POST to
/_gate/loginwith apasswordfield - Include a
redirecthidden field (populated via JS) so users return to the page they were trying to access - Use
{% if show_error %}to conditionally show error messages
Custom Error Page
Hodor also ships with a built-in styled error page for upstream failures. To customize it, set error_template to the path of an HTML file:
environment: ERROR_TEMPLATE: /etc/hodor/error.html volumes: - ./my-error.html:/etc/hodor/error.html:ro
The built-in error template (src/error_template.html) receives these variables:
| Variable | Type | Description |
|---|---|---|
title |
string | The configured title (auto-escaped) |
status_code |
number | HTTP status code such as 502 or 501 |
heading |
string | Short error heading |
message |
string | Human-readable error message |
custom_css |
string | The configured custom_css — include it with {{ custom_css | safe }} to keep the override working in your template |
disable_default_css |
bool | true when disable_default_css is set — custom templates can use it to gate their own base styles |
Building from Source
PASSWORD=secret UPSTREAM=http://localhost:3000 ./target/release/hodor
Tests and Coverage
Run the test suite:
Install cargo-llvm-cov and generate an HTML coverage report:
cargo install cargo-llvm-cov --locked cargo llvm-cov --all-features --workspace --open
Without --open, the report is written to target/llvm-cov/html/index.html. CI uploads the LCOV report to Codacy and publishes the HTML and LCOV reports as a coverage-report workflow artifact.
Docker
Build locally:
docker build -t hodor .
docker run -e PASSWORD=secret -e UPSTREAM=http://host.docker.internal:3000 -p 8080:8080 hodorHealth Checks
Hodor exposes /_gate/health which returns 200 ok whenever the hodor process is serving HTTP. It does not check the upstream, so use it as a liveness probe; for end-to-end readiness, probe a proxied path listed in BYPASS_PATHS instead.
Since hodor runs from a scratch image, there's no shell or utilities inside the container. Use an external probe or your orchestrator's native HTTP health check:
# Kubernetes livenessProbe: httpGet: path: /_gate/health port: 8080 initialDelaySeconds: 2 periodSeconds: 10

