What's new, at a glance #
Five additions, all opt-in — an existing routes.yml/config.json keeps working unchanged until you reach for one of these.
{{ username }}, {{ csrf_token }} and two more — filled in on static files and proxied responses alike.config.json or a shared database.proxyauth stats now reads straight from a Unix socket — no HTTPS round-trip, no admin token.config.json — full reference #
config.json is read once at startup. With the exception of the otpkey field on each user (see Database-backed TOTP), changes to this file require a service restart to take effect — unless the equivalent setting is one of the nine per-vhost overrides, which apply live as soon as routes.yml is reloaded.
Core & networking
secret alone isn't enough to forge a token without also having the matching build (see How a token is built).databases-backed accounts via combined_users().X-Auth-Token header to call /adm/stats, /adm/logs, /adm/revoke and /adm/auth/totp/reset. Treat this like a root credential.num_instances × worker.Authentication & sessions
Every field below can also be set per vhost, overriding this global default for just that vhost — see Per-vhost configuration. These remain the fallback a vhost uses when it doesn't set its own.
session_token cookie after auth, instead of (or alongside) bearer-token auth. Flags set: Secure, HttpOnly, SameSite=Strict./logout sends the visitor afterward. Also used to resolve which page to annotate with an error message on a login failure — see CSRF § Error pages./auth, /adm/auth/totp/get, /logout). null disables cross-origin access entirely./adm/stats//adm/stats/sessions (still gated by token_admin) and the local proxyauth stats socket. Tracks per-token usage counters in memory.Password reset & SMTP
proxyauth reset-password. Optional — omit entirely if not needed. Fields: host, port, username, password, from, timeout_secs. Overridable per vhost.?token=... appended) to set a new password — after a reset email, or automatically on first login for an account with must_change_password: true. Overridable per vhost.Rate limiting
ratelimit_auth and ratelimit_proxy share the same three keys, applied independently to /auth traffic and proxied traffic — a burst of bad login attempts can't drown out real API traffic, or vice versa.
0 disables rate limiting for that traffic class entirely.Requests within the limit pass straight through; anything over the burst gets a clean 429, not a dropped connection. /adm/* admin endpoints are not rate-limited at all — protected only by token_admin, the same as /adm/revoke//adm/logs.
Multi-instance & scaling
Operational
{"type":"local"} for local files, {"type":"loki","host":"..."} to stream to Grafana Loki, {"type":"http"} for a generic HTTP sink.ip_blocklist_refresh_interval_secs) and checked before any other processing — a blocked IP never reaches route matching, auth, or CSRF.X-Forwarded-For — without an entry here for your actual load balancer/CDN, ProxyAuth ignores that header and uses the real TCP peer address for rate limiting and IP-allow checks instead.The User object
X-User-Roles, and usable for route-level access control — see Access control and Group- & role-based access control.roles specifically for route access control — a route lists allowed groups, and a user gets in by belonging to any one of them, without needing their username individually added everywhere./adm/auth/totp/get on first enrollment. See Database-backed TOTP.page_change_password with a fresh single-use token instead of issuing a normal session. Cleared automatically the moment the user sets a new one.secret, token_admin, databases.password, and every user's otpkey are stored in plaintext in config.json (password is the one exception — hashed with Argon2 in place after first startup). A deliberate trade-off for a self-hosted config file: restrict its permissions to the run_user only, and treat the file itself as a credential.How a token is built and verified #
A successful login doesn't just sign a JWT-style payload — the token's construction itself has a build-specific layer on top of the usual encryption, which is what makes sync export/sync import (see SSO across instances) necessary for multi-node deployments rather than sharing secret alone.
Building a token
- Seven values are gathered: the username, a secret+timestamp composite, the running binary's build time, the token's expiry, a per-build random value, the token's ID, and a per-build key-derivation salt.
- These are concatenated in an order that is itself randomized per build — a fresh binary compiled from the same source produces a different shuffle order, derived from a build-time constant baked into that specific binary.
- The shuffled, colon-joined string is hashed with BLAKE3.
- That hash, plus the expiry, the user's index, and the token ID, are joined with
|and encrypted with XChaCha20-Poly1305, using a key derived fromsecretvia HKDF-SHA256 (salted with another per-build constant).
Two binaries built from identical source, with identical config.json, produce tokens that don't validate against each other — the shuffle order and the HKDF salt both differ per build. This is deliberate: it means stealing secret alone is never enough to forge a valid token without the matching binary's build constants too.
Verifying a token
- Decrypt with the same derived key, and split the four
|-delimited fields back out. - Look the user up by the index encoded in the token — not by username — and check the expiry against
token_expiry_seconds. - Recompute the expected hash the same way it was built, using the now-known username, expiry, and token ID from the decrypted payload.
- Compare the recomputed hash against the one embedded in the token, using a constant-time comparison — a plain
!=on the raw bytes would leak timing information about how many leading bytes matched, an actual side-channel against the token's own integrity check. - Check the token ID against the revocation set (see
/adm/revoke) — a structurally valid, unexpired token that's been explicitly revoked is still rejected.
true, the BLAKE3 hash is compared directly. When false (the default), an additional cost function — a deliberately slower "factor hash" — runs on top before the final comparison, trading some throughput for a meaningfully more expensive brute-force target.How a route is matched #
Every incoming request is matched against routes.yml in a fixed order — understanding this order explains a lot of otherwise-surprising behavior, especially on an instance with several vhosts or overlapping prefixes.
The evaluation order
- Regex routes first, in the order they appear in
routes.yml— first match wins, the same way nginx trieslocation ~ patternblocks in file order. - Then every plain-prefix route, longest prefix first. A route matching
/api/v2is tried before one matching/api, regardless of which is listed first in the file — the more specific route always gets first refusal. /(the bare root) is always tried last among plain-prefix routes, whatever its actual listed position — it's the most generic possible prefix, so it only catches what nothing more specific claimed.
Within that order, a route only actually matches if its vhost list also matches the request's Host header (or the route has no vhost at all — see Virtual hosts below).
defined?"} B -->|yes| C["Try each, in routes.yml order —
first pattern match wins"] C -->|matched| VH1{"vhost also
matches Host?"} C -->|none matched| D B -->|no| D["Try plain-prefix routes,
longest prefix first"] D --> VH2{"vhost also
matches Host?"} VH1 -->|yes| USE(["Route selected"]) VH2 -->|yes| USE VH1 -->|no| D VH2 -->|more candidates left| D VH2 -->|no candidates left| NONE(["404 — no route matched"]) classDef start fill:#1a1d25,stroke:#e8ff47,stroke-width:2px,color:#ffffff classDef decision fill:#2a2410,stroke:#e8ff47,stroke-width:2px,color:#ffffff classDef step fill:#14161a,stroke:#7a8296,stroke-width:1.5px,color:#ffffff classDef success fill:#0f2416,stroke:#22c55e,stroke-width:2px,color:#ffffff classDef error fill:#2a1215,stroke:#ef4444,stroke-width:2px,color:#ffffff class A start class B,VH1,VH2 decision class C,D step class USE success class NONE error
/ — their own respective home pages. Before this ordering was vhost-aware end-to-end, whichever route happened to be listed first in the file could silently win for every vhost, serving one site's content on another's domain. Longest-prefix-first plus vhost-matching together are what makes routes.yml's file order irrelevant to the actual result.Prefix matching, precisely
A request path matches a route's prefix if it's an exact match, or if it starts with the prefix followed by /. prefix: "/api" matches /api and /api/users, but not /apikeys — the boundary has to land on a real path segment, not just a shared string of characters.
Core fields #
Every route needs exactly one of two content sources — a proxied backend, or a static file/directory — plus a prefix to match requests against.
static in practice — a route is either proxied or served from disk.static_index too.static points at a directory (e.g. index.html, or a login page).routes:
- prefix: "/api"
target: "http://127.0.0.1:8000"routes:
- prefix: "/"
static: "/var/www/app/public/"
static_index: "login.html"Virtual hosts & per-vhost TLS #
A route with no vhost at all is a catch-all — it matches any Host header, the same behavior every routes.yml had before vhost existed. A route with a vhost list only matches requests for one of those hostnames.
Example.com:8443 and example.com match the same route.[] — catch-allcert and key, both required if either is set. Hot-reloaded on renewal, same as the global certificate — see below for why this needed fixing to survive a Certbot-style symlink swap.certbot renew/certbot check commands.vhosts:
- vhost: ["app.example.com"]
vhost_cert:
cert: "/etc/proxyauth/cert/app.example.com/fullchain.pem"
key: "/etc/proxyauth/cert/app.example.com/privkey.pem"
certbot_renew: true
routes:
- prefix: "/"
target: "http://127.0.0.1:8000"
- prefix: "/admin"
target: "http://127.0.0.1:8001"A vhosts: group is a convenience — it expands into individual routes at load time, each inheriting the group's vhost/vhost_cert/certbot_renew (and every per-vhost override covered next) unless the route sets its own. If more than one route shares a vhost, these only need to be set on one of them — or, cleaner, once on the group.
Per-vhost configuration #
Nine settings that used to be global-only in config.json can now be set on a vhosts: entry in routes.yml instead — letting different domains on the same instance behave completely differently. A vhost that doesn't set one of these simply falls through to the existing global default, so nothing changes for a vhost that doesn't need it.
vhosts:
- vhost: ["app.example.com"]
session_cookie: true
tag_csrf_token: true
login_redirect_url: "/app"
logout_redirect_url: "/"
login_via_otp: true
page_change_password: "https://app.example.com/lost-password.html"
max_age_session_cookie: 3600
cors_origins: ["https://app.example.com"]
smtp:
host: smtp.app.example.com
port: 587
username: noreply@app.example.com
password: "..."
from: "App <noreply@app.example.com>"
timeout_secs: 10
routes:
- prefix: "/"
target: "http://127.0.0.1:8000"
- vhost: ["other.example.com"]
smtp:
host: smtp.another-domain.com
port: 587
username: noreply@other.example.com
password: "..."
from: "Other <noreply@other.example.com>"
timeout_secs: 10
routes:
- prefix: "/"
target: "http://127.0.0.1:9000"The nine fields
session_token cookie at all for this vhost, vs. bearer-token-only auth.session_cookieneed_csrf (the existing per-route opt-out once CSRF is already on somewhere).csrf_token"/"/logout sends the visitor afterward.Max-Age, in seconds.from/timeout must all be set together).smtp blockvhosts: group's routes individually, but setting it on the group itself — as shown above — is almost always the right place: these are vhost-wide concerns, not per-route ones.Vhost login authorization #
A different, earlier gate than the route-level username/groups/roles fields, which only govern access to a specific route's content for someone already logged in. This one decides whether a login attempt on a given vhost's /auth succeeds at all, before any session or route access even enters the picture.
allow_users/allow_groups/allow_roles all empty means nobody can log in on that vhost — not "anyone with valid credentials," which is what the equivalent empty state means for a route's own username/groups/roles. A vhost grants no login access at all until at least one of the three names someone in.vhosts:
- vhost: ["app.example.com"]
allow_groups: ["ops"]
allow_roles: ["admin"]
exclude_users: ["toto"]
routes:
- prefix: "/"
target: "http://127.0.0.1:8000"Here, anyone in the ops group or holding the admin role can log in — except toto, even if toto happens to also be in ops or hold that role.
The four fields
allow_groups/allow_roles as an OR.[]groups is.[]roles is.[][]How the decision is made
exclude_users?"} B -->|yes| DENY(["Denied"]) B -->|no| C{"allow_users,
allow_groups and
allow_roles
all empty?"} C -->|yes| DENY C -->|no| D{"Username in
allow_users,
OR in an allowed
group, OR holds
an allowed role?"} D -->|yes| OK(["Login proceeds —
session/route access
checks continue normally"]) D -->|no| DENY classDef start fill:#1a1d25,stroke:#e8ff47,stroke-width:2px,color:#ffffff classDef decision fill:#2a2410,stroke:#e8ff47,stroke-width:2px,color:#ffffff classDef success fill:#0f2416,stroke:#22c55e,stroke-width:2px,color:#ffffff classDef error fill:#2a1215,stroke:#ef4444,stroke-width:2px,color:#ffffff class A start class B,C,D decision class OK success class DENY error
OIDC provider — overview & flow #
A vhost with oidc: configured turns ProxyAuth into a genuine OpenID Connect provider (OP) for the backend sitting behind it. The backend — Grafana, Nextcloud, GitLab, or anything else that natively speaks OIDC as a relying party (RP) — receives a real, independently-verifiable id_token via the standard authorization code flow, instead of relying on ProxyAuth's own header injection (X-User, X-User-Roles, …) or session cookie.
/auth, session cookies, or TOTP changes for vhosts that don't set oidc:. Where this is set, it's the backend that decides who's authenticated, not ProxyAuth's own required_login/access-control checks.What changes on an oidc-enabled vhost
ProxyAuth's own required_login/session enforcement is bypassed for this vhost's proxied routes — the backend is responsible for its own auth decision via OIDC now, the same way it would be sitting behind any other OIDC provider. What ProxyAuth does still do on this vhost is serve five endpoints, intercepted ahead of normal routing:
| GET /.well-known/openid-configuration | Discovery document |
| GET /jwks.json | Public signing key, for verifying tokens |
| GET /authorize | Where the browser lands to authenticate |
| POST /token | Server-to-server code-for-token exchange |
| GET /userinfo | Claims about the authenticated user |
Every other path on this vhost proxies straight through to the backend, unauthenticated by ProxyAuth itself — exactly as if required_login were never set.
A user still authenticates the normal way
Nothing about how someone proves who they are changes — it's still ProxyAuth's own account store (file or database), the same credential/TOTP verification as everywhere else. What changes is when and how that gets communicated: the login happens at /authorize, packaged as the OIDC login step, and the proof that reaches the backend is a signed token instead of a header or cookie.
The full flow
The /authorize → login detour only happens once per session — a returning visitor with a still-valid ProxyAuth session skips straight from GET /authorize to the redirect-with-code, no login form in between.
OIDC provider — configuration #
One oidc: block per vhost, registering exactly one relying party — the backend sitting behind that vhost. Deliberately one client per vhost, not a list: ProxyAuth's own model is already "one vhost, one backend" everywhere else (target:, backends:), and matching that here keeps the block answering one question — which backend, at which callback URL, is allowed to receive tokens for this vhost's identity.
vhosts:
- vhost: ["grafana.example.com"]
tls: true # required — an OIDC issuer must be https
oidc:
client_id: "grafana"
client_secret_hash: "$argon2id$v=19$m=19456,t=2,p=1$..."
redirect_uris:
- "https://grafana.example.com/login/generic_oauth"
scopes: ["openid", "profile", "email"]
routes:
- prefix: "/"
target: "http://127.0.0.1:3000"The four fields
/token, and that shows up in tokens as the aud claim. Not secret — meant to be public, the same way a browser's own client_id in a public OAuth flow is.redirect_uri a client sends to /authorize must appear in this list byte-for-byte. This is what guards against the classic OIDC/OAuth2 open-redirect-via-authorization-code attack.openid is always implicitly required by the protocol itself regardless of what's listed here.Generating client_secret_hash
Pick a long random value for the secret itself (treat it like an API key, not a password a human types — no length/memorability constraints apply), then hash it with Argon2id the same way ProxyAuth hashes everything else. There's no dedicated CLI command for this yet — until there is, any small script using the same argon2 crate ProxyAuth itself depends on works:
use argon2::password_hash::{PasswordHasher, SaltString, rand_core::OsRng};
use argon2::Argon2;
let secret = "grafana-super-secret-value"; // keep this, plaintext, for the backend's own config
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(secret.as_bytes(), &salt)
.unwrap()
.to_string();
println!("{hash}"); // this goes in routes.yml's client_secret_hashKeep the plaintext secret for the backend's own configuration — routes.yml only ever needs the hash.
Configuring the backend
Any application that speaks standard OIDC configures against the same five URLs regardless of which one it is. Grafana, as a concrete example:
[auth.generic_oauth]
enabled = true
name = ProxyAuth
client_id = grafana
client_secret = grafana-super-secret-value
scopes = openid profile email
auth_url = https://grafana.example.com/authorize
token_url = https://grafana.example.com/token
api_url = https://grafana.example.com/userinfo
redirect_uri = https://grafana.example.com/login/generic_oauthVerifying it's working
$ curl https://grafana.example.com/.well-known/openid-configuration $ curl https://grafana.example.com/jwks.json
Both should return JSON with the expected URLs and a public key respectively. The real end-to-end test is clicking "Sign in with ProxyAuth" from the backend's own login page and following the flow through.
required_login: true on this vhost's own routes — it no longer means anything meaningful once the backend is handling its own auth via OIDC, and the two mechanisms aren't designed to layer.OIDC provider — endpoint reference #
Every check below is enforced in this order — each one earns its place in the chain that turns "someone has a code" into "here is cryptographic proof of who logged in."
RS256 only), and code_challenge_methods_supported: ["S256"].response_type=code, client_id, redirect_uri, scope, state, nonce, code_challenge, code_challenge_method=S256client_id/redirect_uri are validated first and independently — an invalid one shows an error page directly, never a redirect (that's the exact mechanism an open-redirect-via-OAuth attack relies on). Every other validation failure redirects back to the now-confirmed-valid redirect_uri with ?error=...&state=.... PKCE is mandatory — no confidential-client exemption.client_secret_basic or client_secret_post, checked before the code itself — a wrong secret never reveals whether the code was otherwise validgrant_type=authorization_code, code, redirect_uri, client_id, client_secret, code_verifierclient_id match → byte-for-byte redirect_uri match (RFC 6749 §4.1.3) → PKCE (SHA256 of code_verifier, constant-time compared){"access_token", "token_type": "Bearer", "expires_in", "id_token"} — both tokens are signed RS256 JWTs, 1 hour lifetimeAuthorization: Bearer <access_token> — no session-cookie fallback, this is meant to be called server-to-serversub always; email/email_verified only if the email scope was granted; name only if profile was grantedAuthorization codes
Single-use, validated and consumed atomically (one LMDB read-write transaction — the same fix applied to ProxyAuth's own password-reset tokens after a real race condition was found there), and deliberately short-lived: 120 seconds, enough for the immediate browser redirect and server-to-server exchange, not for anything else.
Token verification, precisely
id_token carries iss/sub/aud/exp/iat/nonce — meant to be verified by the relying party's own OIDC library against /jwks.json, checking aud matches its own client_id. access_token additionally carries scope, deliberately without aud — it's only ever verified by ProxyAuth's own /userinfo, which also checks the token's iss matches the vhost the request actually arrived on, since every oidc-enabled vhost currently shares one signing key.
Access control #
Route-level access control — what an already-logged-in visitor can reach. Not to be confused with vhost login authorization, which decides whether someone can log in on a vhost at all, before any route access is relevant.
groups.roles.tag_csrf_token.username, groups and roles combine as an OR — a user reaches the route if any condition holds. All three empty means any authenticated account may use the route — the opposite default from vhost login authorization, and worth keeping straight: an empty list here is permissive, an empty list there is a lockout.
routes:
- prefix: "/admin"
target: "http://127.0.0.1:8000"
groups: ["ops"]
roles: ["admin"]Group- and role-based access control #
Alternatives to enumerating individual usernames on every route, for when the same set of people needs access to many routes.
A user reaches a route if any of three conditions hold: their username is explicitly listed in username, they belong to an allowed groups entry, or they hold an allowed roles entry — see Access control. groups and roles are checked against the matching fields on the User object (or the equivalent database columns).
roles vs. groups — a real distinction, not two names for the same thing
roles predates route-level access control and originally did one thing only: get forwarded to the backend as an X-User-Roles header, for the backend's own authorization logic to consume. That still happens. roles can also now gate route access directly, the same way groups does — the two mechanisms coexist without conflict, since a role either matching a route's roles list or not is independent of whatever the backend chooses to do with the same header.
groups was added specifically for access control and has no header side-effect — use it when you want a permission grouping that has no meaning to the backend at all, and roles when the backend also needs to know.
What the backend sees
X-User: alice
X-User-Roles: admin,ops
X-User-Groups: engineeringPath handling #
target URL, ignoring the request's own sub-path. Prevents sub-path traversal and open-redirect-style tricks against the backend.http://localhost:8000/api/endpoint → a request for /api/endpoint/../other still forwards to exactly /api/endpoint.CSRF #
CSRF protection has three layers, each answering a different question:
config.json — is CSRF protection on anywhere at all.All three are combined with logical AND at the actual check: session cookies on, and CSRF resolved-enabled for the vhost, and this route requires it. Turning any one off is enough to skip both injection and validation for that scope.
Where the token gets filled in
Two independent mechanisms fill {{ csrf_token }} into HTML, and either can apply to the same response:
- The CSRF-specific mechanism — always active on proxied responses once
session_cookie+ CSRF are on for the vhost, regardless oftag_proxyauth. - The
tag_proxyauthmechanism — also fills{{ csrf_token }}, on top of{{ username }}/{{ proxyauth_version }}/{{ proxyauth_id }}, on both static files and proxied responses — see Dynamic tags. It also respectstag_csrf_token: off means the tag is left as literal text there too.
Error pages
Applies when session_cookie is on. Errors are embedded in an HTML comment block for the page to reveal:
<!-- BEGIN_BLOCK_ERROR -->
<!--
<div id="error_display">
{{ error }}
</div>
-->
<!-- END_BLOCK_ERROR -->On a login failure, ProxyAuth uncomments this block and fills {{ error }} in — on a success, the block stays commented out and invisible. The page this happens to is resolved the same vhost-aware way any other request is routed (see How a route is matched) — critical on a multi-vhost instance, where more than one vhost commonly has its own route at the exact same path.
Password reset & first-login forced change #
Two related but distinct flows, both landing on the same page_change_password page with a single-use token.
Admin-initiated reset
proxyauth reset-password --username alice [--vhost app.example.com] generates a single-use, time-limited link and emails it via smtp. Alice's current password keeps working until she actually follows the link and sets a new one — this doesn't lock her out immediately, it just gives her a way back in without needing the old password.
Forced change on first login
An account created with must_change_password: true — typically right after an admin sets a temporary password — is redirected to page_change_password on its very next successful login, instead of getting a normal session. The flag clears automatically the moment a new password is actually set.
smtp configured, page_change_password configured, the user has an email on file) is checked up front for the admin-initiated flow, and every problem found is reported together in one run — not one at a time across repeated attempts.Logging #
false silences the per-request access-log line for this route only — warn!/error! diagnostics are unaffected, which is usually what "disable logging on this noisy endpoint" actually means.logging.enabledlog.type: "loki" in config.json. See the full wiki for the complete transport/format reference.Compression #
gzip, deflate and brotli, negotiated per request via Accept-Encoding. Configurable per route — algorithm, level, minimum size before compressing.
routes:
- prefix: "/"
target: "http://127.0.0.1:8000"
compression:
algorithm: "br"
level: 5
min_size: 1024Content-Encoding from the CSRF/tag injection mechanisms above — those re-compress with whatever the backend originally used instead, to avoid double-encoding.Custom response headers #
Arbitrary headers — CSP, HSTS, X-Frame-Options, or anything else — added to every response a route produces, proxied and static alike.
routes:
- prefix: "/"
target: "http://127.0.0.1:8000"
headers:
Content-Security-Policy: "default-src 'self'; script-src 'self' 'unsafe-inline'"
Strict-Transport-Security: "max-age=63072000; includeSubDomains"
X-Frame-Options: "DENY"Also settable on a vhosts: group — a route's own headers is merged with its group's, not replaced by it: the route's own entries win only on a key both define. Lets you set something like HSTS once for a whole vhost, then override just one header on a specific route.
Anti-Replay #
An opt-in request-signing layer, separate from the base token model — protects against a captured, otherwise-valid request being resent later by an attacker who doesn't have the signing key itself.
plugin_anti_replay option is also true — in that case, set this to false on routes that don't need a signed request, like a health check.Each signed request carries a timestamp and a nonce; ProxyAuth rejects anything outside the accepted time window, or any nonce it's already seen — a captured request can't simply be replayed even by someone who intercepted it in full.
Caching #
false sends no-store, no-cache, must-revalidate, max-age=0 plus Pragma: no-cache on every response — appropriate for anything showing per-user or otherwise sensitive content.cache is on, the max-age value sent in Cache-Control: public, max-age=... for this route's static files.cache: false on any route serving a login page or anything with tag_proxyauth: true — a cached copy of a page containing someone else's {{ username }} or a stale {{ csrf_token }} is exactly the kind of bug this flag exists to prevent.Client certificates (mTLS to the backend) #
ProxyAuth can present its own client certificate when connecting to a backend that requires mutual TLS — the backend authenticates ProxyAuth itself, independent of whatever authenticates the original visitor.
routes:
- prefix: "/secure-api"
target: "https://internal-service:8443"
cert:
file: "/etc/proxyauth/certs/client.p12"
password: "..."Connections to the same backend are pooled and reused across requests rather than renegotiating TLS every time — the pool is keyed by target and certificate together, so two routes pointing at the same backend with different client certificates each get their own pool, never accidentally sharing a connection authenticated as the wrong identity.
Egress proxy #
Route a proxied request through another HTTP proxy before it reaches the real target — for a backend only reachable through a corporate egress proxy, for instance.
http://host:portLoad balancing (backends) #
A route can point at several backends instead of one, weighted round-robin, with automatic failover — a request that times out against one backend is retried against the next, not dropped.
routes:
- prefix: "/api"
backends:
- url: "http://10.0.0.1:8000"
weight: 2
- url: "http://10.0.0.2:8000"
weight: 1
- url: "http://10.0.0.3:8000"
weight: -1 # failover only — never gets traffic unless the others are all down-1 removes a backend from normal rotation entirely, used only when every weighted backend is currently down.A backend that times out is put on a short cooldown rather than being retried immediately on every subsequent request — avoiding a thundering-herd of failed attempts against something that's genuinely down.
Filters (fine-grained request matching) #
ACL rules evaluated before forwarding — every condition in allow must match for the request to proceed, checked against method, path, headers, query parameters, or the request body itself.
routes:
- prefix: "/webhook"
target: "http://127.0.0.1:9000"
filters:
default_allow: false
allow:
- method: "POST"
header:
X-Webhook-Secret: "expected-value"allow condition matches at all.method, path, header, query, body_raw, body_json.Database-backed users #
An alternative to hand-editing users in config.json — accounts live in PostgreSQL or MySQL instead, shared automatically across every instance pointed at the same database. File-based and database-backed accounts coexist freely; a request is checked against both.
"databases": {
"kind": "postgres",
"url": "postgres://user:pass@localhost/proxyauth",
"connect_timeout_secs": 5,
"refresh_interval_secs": 30,
"incremental_window_secs": 300
}Schema
Six tables: users (username, password hash, otpkey, timestamps, a soft-delete flag, must_change_password), plus user_allow, user_roles, groups/user_groups, and user_email for the one-to-many fields — mirroring the file-based User object's shape exactly, so the two backends behave identically from the application's point of view.
Sync model: incremental scan + full scan
Two scans run on independent timers, at different costs:
incremental_window_secs (default 300s — deliberately longer than the scan interval itself, so a change can never fall in the gap between two scans and be missed). This is what makes a freshly created or edited account usable within seconds, not minutes.A deleted account is only ever concluded from a genuinely fresh full-table read, never from a cache — see below.
Local fallback cache
A local LMDB cache mirrors the last successful full read, purely as a startup/outage resilience layer — consulted only when the real database can't be reached at all, never part of the normal read path otherwise. Read-only from the application's point of view for almost everything: db-add-user/db-delete-user always require a live connection and refuse outright if the database is unreachable, rather than writing to the cache instead. The one exception is TOTP enrollment/reset, which patches just the affected user's otpkey in the cache immediately — see Database-backed TOTP for why that specific case needed an exception.
Managing users via the CLI
$ proxyauth db-add-user --username alice --password '...' --roles admin --groups ops $ proxyauth db-delete-user --username alice $ proxyauth db-sync-cache # force an immediate full scan $ proxyauth db-restore-from-cache # rebuild in-memory state from the LMDB cache $ proxyauth db-clear-cache
Database-backed TOTP #
TOTP enrollment and reset now work identically whether an account lives in config.json or in a shared PostgreSQL/MySQL database — the same two routes detect automatically which one a given user actually belongs to.
Two storage backends, two ways of staying in sync
Both problems come from the same root cause — the running server keeps its own in-memory view of accounts, and a raw write to storage doesn't update that view by itself — but each backend solves it differently:
AppState.config is loaded once into memory as an immutable snapshot. An in-memory overlay, checked before that snapshot at both login and re-enrollment time, bridges the gap until the next restart.config.json?"} B -->|yes| C["Write config.json,
update in-memory overlay"] B -->|no| D{"Database
configured?"} D -->|yes| E["Write to the database,
refresh in-memory mirror,
patch LMDB fallback cache"] D -->|no| F(["Error: user not found anywhere"]) C --> G(["Change is live immediately —
no restart needed"]) E --> G classDef start fill:#1a1d25,stroke:#e8ff47,stroke-width:2px,color:#ffffff classDef decision fill:#2a2410,stroke:#e8ff47,stroke-width:2px,color:#ffffff classDef step fill:#14161a,stroke:#7a8296,stroke-width:1.5px,color:#ffffff classDef success fill:#0f2416,stroke:#22c55e,stroke-width:2px,color:#ffffff classDef error fill:#2a1215,stroke:#ef4444,stroke-width:2px,color:#ffffff class A start class B,D decision class C,E step class G success class F error
The LMDB fallback cache
Database-backed accounts have a third layer behind the two above: a local LMDB cache that stands in for the real database if it's ever unreachable — most importantly, right at startup. This cache is only a resilience fallback, not part of the normal read path. But an enrollment or reset patches it immediately alongside the real database write, specifically so a database outage occurring shortly afterward can't cause ProxyAuth to fall back to a stale, since-replaced secret.
{"username": "alice", "password": "..."}409 if a secret already exists — see self-service re-enrollment for the opt-out.X-Auth-Token: <token_admin>{"username": "alice"}Self-service re-enrollment #
/adm/auth/totp/get normally refuses to hand out a new secret to an account that already has one — even with the correct password — specifically so a stolen password alone can't be used to silently take over someone's second factor. allow_totp_reenroll: true on a vhost removes that restriction: a user with a working username/password gets a brand-new TOTP secret in one request, no admin, no prior /adm/auth/totp/reset call.
vhosts:
- vhost: ["internal-tool.example.com"]
allow_totp_reenroll: trueWhat this costs you
TOTP stops protecting against a stolen or guessed password the way two-factor authentication is meant to, for any account on a vhost where this is on. Whoever currently holds the password also controls the second factor — they can silently re-enroll their own authenticator app, and the legitimate user's old one just stops working, with no built-in notification.
Meant for specific low-stakes vhosts where the operational cost of admin-mediated resets outweighs the reduced protection — not a default worth reaching for generally.
Local stats socket #
proxyauth stats reads live stats directly from the running instance over a local Unix domain socket — no HTTPS round-trip, no admin token needed for this specific, local-only path.
$ proxyauth stats
requests/sec (last): 42
avg req/sec (10s): 38.50
avg req/sec (60s): 35.20
total requests: 918273
active sessions: 7
uptime: 1d 1h 2m 03s/opt/proxyauth/run/stats.sock, created by the server itself at startup. Its filesystem permissions (owner-only, inside a directory that's itself owner-only) are the authentication — the same job token_admin does for the equivalent GET /adm/stats endpoint, which remains available for monitoring systems that aren't running on the same host.SSO across multiple instances #
Since token construction depends on per-build constants, not just secret, two independently-built ProxyAuth instances don't validate each other's tokens by default — even with identical config.json. sync export/sync import is how a fleet of instances is made to agree on the same constants, achieving real single-sign-on across them.
How it works
- On the source instance,
sync exportgenerates a fresh OpenPGP certificate (via Sequoia), encrypts its own private key material with a passphrase derived from that instance'sconfig.jsonsecret, and writes it tokey.asc. It then encrypts its own current build constants — version, build time, build random value, two build seeds, a build epoch, an HKDF salt, and the shuffle order — intodata.pgp, addressed to that same certificate. - Both files are transferred to the target instance (out of band —
scp, a shared volume, however you'd move any other secret). - On the target instance,
sync importuses its own (matching)secretto unlockkey.asc's private key, then decryptsdata.pgpwith it. - The decrypted build constants overwrite the target instance's own, in a runtime-mutable global — not a compile-time constant, despite originating from one. From that point on, this instance builds and verifies tokens exactly as the source instance would.
$ proxyauth sync export Key export Success (secret key encrypted with AppConfig password) # produces ./key.asc and ./data.pgp
$ proxyauth sync
secret in config.json for this to work at all — secret is what protects key.asc's private key material in the first place. Sync doesn't replace matching secrets across instances, it solves the other half of the problem (the per-build constants) that a matching secret alone doesn't cover.CLI reference #
Every proxyauth subcommand switches to run_user automatically (reading it from config.json while still root) — the command itself still needs to be launched as root so that switch is possible.
Setup & lifecycle
proxyauth user/group, the config/cert directories with correct ownership and permissions. --insecure relaxes some of those permissions, for local testing only.Database-backed users
--password prompts interactively (hidden input). --email is authoritative, not a merge — omitting it entirely clears any email(s) already on file.--force.--force.Accounts & access
smtp and page_change_password can be set per vhost now — name --vhost explicitly to use that vhost's own values instead of the global default (this command has no live request to resolve one from automatically). Omit it to use the global default, same as always. See Password reset.groups, for roles.TLS & certificates
certbot_renew: true scan uses. Doesn't require certbot_renew: true to also be set — running this by hand is itself sufficient intent.Operations
Admin routes reference #
Every /adm/* route requires X-Auth-Token: <token_admin> and is not rate-limited — the admin token is the only protection, so treat it like a root credential.
stats: true in config.jsonproxyauth stats reads locally over the Unix socket instead.{"token_id": "..."}{"username": "alice"}