GitHub - michidk/hodor: A tiny reverse proxy that gates any web app behind a single shared password. No users, no database, no OAuth — just one binary, one password, one login page.

GitHub

11 min read Original article ↗

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.

Hero image for the hodor reverse proxy

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 scratch image (~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:

Hodor login page protecting a VibePod preview

Configuration

Hodor uses layered configuration. Each layer overrides the previous:

  1. Defaults — sensible built-in values
  2. hodor.toml — optional config file in the working directory
  3. 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 return 429 with a Retry-After header.

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/mcp matches 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*b and /a/*/b are 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 — returns ok (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-For and X-Forwarded-Proto headers on proxied requests
  • Strips standard hop-by-hop headers and any additional headers named by Connection
  • Replaces Host with the upstream authority by default; set PRESERVE_HOST=true for 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.

  1. The form must POST to /_gate/login with a password field
  2. Include a redirect hidden field (populated via JS) so users return to the page they were trying to access
  3. 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 hodor

Health 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

License

MIT