v1.1.1 · Apache 2.0 · by vBlackOut
Multi-tenant documentation

One instance.
Many vhosts. Each its own rules.

Every mechanism ProxyAuth is built on, in one place — how a request is matched to a route, how a token is built and verified, what every field in config.json and routes.yml actually does, and the multi-tenant capabilities that let a single instance host several domains with genuinely independent behavior. Not a copy of the code — the reasoning behind it.

🏢 Per-vhost config 🔐 Login authorization 🏷️ Dynamic tags 🗄️ Database TOTP 🔑 OIDC provider
34
documented sections
5
OIDC provider endpoints
1.1.1
current release

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.

🏢
Per-vhost configuration
Session cookies, CSRF, redirects, SMTP and CORS can all differ from one vhost to the next.
🔐
Vhost login authorization
Decide who's even allowed to log in on a given vhost — independent of what a route lets them reach afterward.
🏷️
Dynamic tags in pages
{{ username }}, {{ csrf_token }} and two more — filled in on static files and proxied responses alike.
🗄️
Database-backed TOTP
2FA enrollment and reset now work identically whether an account lives in config.json or a shared database.
🔓
Self-service re-enrollment
An explicit, narrow opt-out from admin-mediated 2FA resets — for the vhosts where that trade-off makes sense.
📊
Local stats socket
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

secretrequired
Cryptographic signing secret tokens are derived from — a random value of 64+ characters. Combined at startup with a per-build salt via HKDF-SHA256 into the actual encryption key, so the same secret alone isn't enough to forge a token without also having the matching build (see How a token is built).
token_expiry_secondsrequired
Seconds before an issued token expires.
range: 1 – 31,536,000 (5 years)
usersarray
File-based accounts. See The User object below for every field. Merged at request time with any databases-backed accounts via combined_users().
default: []
token_adminstring
Admin token, required in the X-Auth-Token header to call /adm/stats, /adm/logs, /adm/revoke and /adm/auth/totp/reset. Treat this like a root credential.
hostarray or string
Address(es) ProxyAuth listens on — a bare string is accepted for backward compatibility, but an array lets one instance bind several addresses at once (IPv4 and IPv6 together, for instance).
default: 0.0.0.0
portinteger
Listening port.
range: 1 – 65535, default 8080
workerinteger
Worker threads per instance — should roughly match available CPU cores.
default: 4
num_instancesinteger
Number of ProxyAuth processes to launch. Total concurrency is num_instances × worker.
default: 2
tlsboolean
Enable HTTPS/TLS.
default: false
max_connectionsinteger
Maximum simultaneous connections accepted.
pending_connections_limitinteger
Backlog of connections waiting to be accepted.
client_timeoutinteger, ms
How long ProxyAuth waits for a client to finish sending its request.
keep_aliveinteger, ms
Keep-alive duration for idle client connections.
max_idle_per_hostinteger
Maximum idle pooled connections kept open per backend host, reused across requests instead of renegotiating each time.
range: 0 – 3000, default 50
max_body_sizeinteger, bytes
Largest request body ProxyAuth accepts before rejecting it outright.
fastboolean
Trades some request-time flexibility for raw throughput on the hot proxy path.
default: false

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.

login_via_otpboolean
Require a TOTP code at login, in addition to username/password.
default: false
session_cookieboolean
Issue/check a session_token cookie after auth, instead of (or alongside) bearer-token auth. Flags set: Secure, HttpOnly, SameSite=Strict.
default: false
max_age_session_cookieinteger, seconds
Session cookie lifetime.
range: 60 – 31,536,000, default 3600
login_redirect_urlstring
Where an already-authenticated visitor, or a fresh successful login, gets sent.
default: "/"
logout_redirect_urlstring
Where /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.
csrf_tokenboolean
Master switch for CSRF protection.
default: true
cors_originsarray, or null
Origins permitted to make cross-origin, credentialed requests to auth-related endpoints (/auth, /adm/auth/totp/get, /logout). null disables cross-origin access entirely.
default: null
statsboolean
Enables /adm/stats//adm/stats/sessions (still gated by token_admin) and the local proxyauth stats socket. Tracks per-token usage counters in memory.
default: false
timezonestring
IANA timezone used for token timestamps.
examples: Europe/Paris, UTC, America/New_York

Password reset & SMTP

smtpobject
SMTP server used to send password-reset links via proxyauth reset-password. Optional — omit entirely if not needed. Fields: host, port, username, password, from, timeout_secs. Overridable per vhost.
page_change_passwordstring
The URL a user's browser is sent to (with ?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.

requests_per_secondinteger
Sustained requests per second allowed. 0 disables rate limiting for that traffic class entirely.
default: 0
burstinteger
Extra requests allowed above the sustained rate before blocking kicks in.
default: 1
block_delayinteger, ms
How long a client that exceeded its limit is held before its next request is even considered.
default: 500

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

redisstring
Redis URL for multi-node token revocation sync — Redis is the sync bus, LMDB remains the source of truth on each node. Without this, revoking a token on one instance doesn't affect any other.
example: redis://redis_server:6379
databasesobject
PostgreSQL/MySQL connection for shared, database-backed users across every instance. See Database-backed users for the full schema and sync model.
blakegatearray
External BlakeGate WebSocket endpoints ProxyAuth pushes live in-memory config changes to, near-instantly. Experimental — for very large (1,000,000+ user) deployments.

Operational

run_user / run_groupstring
The unprivileged user/group ProxyAuth drops to after binding its listeners — every resource that needs root (a low port, a TLS certificate directory) is set up first, then privileges are dropped for the actual request-handling lifetime.
default: proxyauth
logobject
Logging destination. {"type":"local"} for local files, {"type":"loki","host":"..."} to stream to Grafana Loki, {"type":"http"} for a generic HTTP sink.
default: {"type":"local"}
ip_blocklistsarray
External IP blocklist sources, refreshed on a timer (ip_blocklist_refresh_interval_secs) and checked before any other processing — a blocked IP never reaches route matching, auth, or CSRF.
trust_proxy_forward_forarray
Trusted upstream proxy IPs allowed to set 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

username / passwordrequired
Login name, and the Argon2id hash of the password (never store plaintext).
allowarray
IP/CIDR allow-list for this specific account — a login attempt from outside it is rejected regardless of the password's correctness.
rolesarray
Forwarded to the backend as X-User-Roles, and usable for route-level access control — see Access control and Group- & role-based access control.
groupsarray
An alternative to 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.
emailarray
Address(es) used for password-reset links.
otpkeystring
TOTP secret (base32). Not normally set by hand — populated by /adm/auth/totp/get on first enrollment. See Database-backed TOTP.
must_change_passwordboolean
The next successful login redirects to 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.
default: false
⚠️
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

  1. 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.
  2. 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.
  3. The shuffled, colon-joined string is hashed with BLAKE3.
  4. 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 from secret via 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

  1. Decrypt with the same derived key, and split the four |-delimited fields back out.
  2. Look the user up by the index encoded in the token — not by username — and check the expiry against token_expiry_seconds.
  3. Recompute the expected hash the same way it was built, using the now-known username, expiry, and token ID from the decrypted payload.
  4. 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.
  5. Check the token ID against the revocation set (see /adm/revoke) — a structurally valid, unexpired token that's been explicitly revoked is still rejected.
fastconfig.json
When 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.
default: false (secure mode)

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

  1. Regex routes first, in the order they appear in routes.yml — first match wins, the same way nginx tries location ~ pattern blocks in file order.
  2. Then every plain-prefix route, longest prefix first. A route matching /api/v2 is tried before one matching /api, regardless of which is listed first in the file — the more specific route always gets first refusal.
  3. / (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).

flowchart TD A(["Incoming request"]) --> B{"Any regex routes
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
⚠️
A real bug this fixed: on a multi-vhost instance, two completely unrelated vhosts commonly both declare a route at / — 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.

prefixrequired
The URL prefix this route matches. See How a route is matched for the full algorithm.
targetstring
The backend URL to proxy this route to. Mutually exclusive with static in practice — a route is either proxied or served from disk.
staticstring
A file or directory path to serve directly from disk — no backend involved at all. A directory needs static_index too.
static_indexstring
The file served for the directory root when static points at a directory (e.g. index.html, or a login page).
required_loginboolean
Whether a valid session is required to reach this route at all — works for proxied and static routes alike.
default: false
yaml — proxied route
routes:
  - prefix: "/api"
    target: "http://127.0.0.1:8000"
yaml — static route
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.

vhostarray
Hostnames this route answers to. Case-insensitive, port-stripped before comparison — Example.com:8443 and example.com match the same route.
default: [] — catch-all
vhost_certobject
A TLS certificate/key this vhost should present instead of the server's single global one, selected via SNI at handshake time. Two keys: cert 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_renewboolean
Enables ProxyAuth's own automatic Let's Encrypt renewal for this vhost's certificate — see the CLI's certbot renew/certbot check commands.
default: false
yaml — grouping routes under one vhost
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.

ℹ️
Certificate hot-reload watches the containing directory, not the certificate file itself — deliberately, since a file-level watch dies the moment a renewal replaces the file's underlying inode (exactly what both Certbot's symlink swap and ProxyAuth's own atomic-rename renewal do). A directory's own inode doesn't change when files inside it do, so the watch survives every subsequent renewal, not just the first.

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.

yaml
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_cookieboolean
Whether ProxyAuth issues/checks a session_token cookie at all for this vhost, vs. bearer-token-only auth.
default: the global session_cookie
tag_csrf_tokenboolean
Whether CSRF protection — injection and server-side validation — is on at all for this vhost. Genuinely independent per vhost, unlike need_csrf (the existing per-route opt-out once CSRF is already on somewhere).
default: the global csrf_token
login_redirect_urlstring
Where an already-authenticated visitor (a still-valid session cookie) gets sent instead of the login form, and where a fresh login redirects to on success.
default: the global value, or "/"
logout_redirect_urlstring
Where /logout sends the visitor afterward.
default: the global value
login_via_otpboolean
Whether a TOTP code is required at login, in addition to username/password.
default: the global value
page_change_passwordstring
The external page a password-reset link points visitors at, for this vhost's accounts.
default: the global value
max_age_session_cookieinteger
The session cookie's Max-Age, in seconds.
default: the global value
cors_originsarray
Origins allowed to make cross-origin requests to this vhost. Whole-list replacement, not merged with the global list.
default: the global list
smtpobject
The SMTP server used to send this vhost's password-reset emails — lets different domains send through different mail servers. Whole-object replacement (host/port/credentials/from/timeout must all be set together).
default: the global smtp block
ℹ️
Each field can also be set once on a vhosts: 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.

⚠️
The default is the opposite of route-level access control. Leaving 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.
yaml
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_usersarray
Usernames allowed to log in via this vhost. Combines with allow_groups/allow_roles as an OR.
default: []
allow_groupsarray
Groups allowed to log in via this vhost, checked the same way route-level groups is.
default: []
allow_rolesarray
Roles allowed to log in via this vhost, checked the same way route-level roles is.
default: []
exclude_usersarray
Usernames explicitly denied login on this vhost, regardless of the three allow-fields above — an exclusion always wins, even over a direct username match or membership in an allowed group/role. For carving out an exception without restructuring the allow lists — every member of an allowed group except one specific account.
default: []

How the decision is made

flowchart TD A(["Login attempt on this vhost"]) --> B{"Username in
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.

ℹ️
This is the opposite direction from ProxyAuth's own login system, and both coexist — nothing about /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-configurationDiscovery document
GET /jwks.jsonPublic signing key, for verifying tokens
GET /authorizeWhere the browser lands to authenticate
POST /tokenServer-to-server code-for-token exchange
GET /userinfoClaims 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

sequenceDiagram participant B as Browser participant RP as Backend (relying party) participant OP as ProxyAuth (this vhost) B->>RP: Visits a protected page RP->>B: Redirect to /authorize (+ PKCE challenge) B->>OP: GET /authorize alt No valid ProxyAuth session OP->>B: Redirect to login (return_to=/authorize?...) B->>OP: POST /auth (username/password/TOTP) OP->>B: Redirect back to /authorize end OP->>B: Redirect to RP's redirect_uri (+ code) B->>RP: GET redirect_uri?code=... RP->>OP: POST /token (code + client_secret + PKCE verifier) OP->>RP: id_token + access_token RP->>OP: GET /userinfo (Bearer access_token) OP->>RP: claims (sub, email, name...) RP->>B: Session established, page loads

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.

routes.yml
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

client_idrequired
The identifier the backend presents at /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.
client_secret_hashrequired
Argon2id hash of the client secret — verified the same way a user password is, never stored or compared as plaintext. See below for how to generate one.
redirect_urisrequired
Exact-match only — no prefix or wildcard matching. Every 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.
scopesarray
Scopes this client may request. openid is always implicitly required by the protocol itself regardless of what's listed here.
default: ["openid", "profile", "email"]

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:

rust
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_hash

Keep 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:

grafana.ini
[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_oauth

Verifying it's working

bash
$ 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.

⚠️
Don't set 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."

GET/.well-known/openid-configuration
ReturnsThe standard OIDC discovery document — every other endpoint's URL, supported scopes, signing algorithm (RS256 only), and code_challenge_methods_supported: ["S256"].
GET/jwks.json
ReturnsThe public half of the RS256 signing key, in JWKS format — what lets any standard OIDC client library verify a token without ever talking to ProxyAuth directly.
GET/authorize
Paramsresponse_type=code, client_id, redirect_uri, scope, state, nonce, code_challenge, code_challenge_method=S256
Behaviorclient_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.
POST/token
Authclient_secret_basic or client_secret_post, checked before the code itself — a wrong secret never reveals whether the code was otherwise valid
Paramsgrant_type=authorization_code, code, redirect_uri, client_id, client_secret, code_verifier
ChecksClient auth → atomic single-use code validation → client_id match → byte-for-byte redirect_uri match (RFC 6749 §4.1.3) → PKCE (SHA256 of code_verifier, constant-time compared)
Returns{"access_token", "token_type": "Bearer", "expires_in", "id_token"} — both tokens are signed RS256 JWTs, 1 hour lifetime
GET/userinfo
AuthAuthorization: Bearer <access_token> — no session-cookie fallback, this is meant to be called server-to-server
Returnssub always; email/email_verified only if the email scope was granted; name only if profile was granted

Authorization 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.

usernamearray
Usernames allowed to reach this route.
groupsarray
Groups allowed to reach this route, checked against the account's groups.
rolesarray
Roles allowed to reach this route, checked against the account's roles.
need_csrfboolean
Whether this specific route requires a valid CSRF token, once CSRF is already enabled for the vhost at all (see CSRF). Independent of, and a level below, tag_csrf_token.
default: true

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.

yaml
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

headers forwarded to every proxied backend
X-User: alice
X-User-Roles: admin,ops
X-User-Groups: engineering

Path handling #

secure_pathboolean
Forces every request on this route to be forwarded strictly to the exact target URL, ignoring the request's own sub-path. Prevents sub-path traversal and open-redirect-style tricks against the backend.
Example: target http://localhost:8000/api/endpoint → a request for /api/endpoint/../other still forwards to exactly /api/endpoint.
default: false

CSRF #

CSRF protection has three layers, each answering a different question:

csrf_tokenboolean, global
The master switch in config.json — is CSRF protection on anywhere at all.
default: true
tag_csrf_tokenboolean, per-vhost
Overrides the master switch for one specific vhost — injection and server-side validation together. See Per-vhost configuration.
need_csrfboolean, per-route
Once CSRF is on for the vhost, does this route participate. See Access control.

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 of tag_proxyauth.
  • The tag_proxyauth mechanism — also fills {{ csrf_token }}, on top of {{ username }}/{{ proxyauth_version }}/{{ proxyauth_id }}, on both static files and proxied responses — see Dynamic tags. It also respects tag_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:

html — error block format
<!-- 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.

ℹ️
Every prerequisite (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 #

logboolean
Access logging for this route. 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.
default: inherit from the vhost group, then the global logging.enabled
log_filestring
Send this route's access log lines to a separate file instead of the default one — useful for isolating one vhost's traffic for its own analysis or retention policy.
ℹ️
Global logging also supports streaming to Grafana Loki — set log.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.

yaml
routes:
  - prefix: "/"
    target: "http://127.0.0.1:8000"
    compression:
      algorithm: "br"
      level: 5
      min_size: 1024
ℹ️
Never applied to a response that's already compressed by the backend, or to one already carrying a Content-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.

yaml
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.

⚠️
A header name or value that isn't valid for an HTTP header is skipped with a warning logged — never turns the whole response into an error. Not applied to pre-route-match rejections (an IP-blocklist 403, a CORS preflight rejection) — those aren't "this route's content."

Dynamic tags in pages #

Four template tags, substituted server-side in static files and proxied responses alike, gated per route by tag_proxyauth: true.

{{ username }}
The signed-in visitor's username — checked independently of whether the current route actually requires login, so a public page can still greet an already-logged-in visitor. Left as the literal tag text if nobody's signed in.
{{ csrf_token }}
A freshly generated, signed CSRF token — the same one /auth submissions are checked against. Also needs tag_csrf_token (or the global csrf_token) resolving to enabled — off means the tag is left untouched rather than filled with a token nobody's going to check.
{{ proxyauth_version }}
The running build's version.
{{ proxyauth_id }}
The running build's instance ID.

Both a spaced ({{ username }}) and unspaced ({{username}}) form are recognized for every tag.

tag_proxyauth: why it's opt-in

Off by default, and — unlike most per-route settings — not inherited-then-defaulted-on anywhere. Substitution means reading the whole response body as text and scanning it on every matching request; a route that doesn't use any of these tags shouldn't pay that cost for nothing. Only text/html responses are scanned even when it's on.

A login page served entirely as static files

routes.yml
vhosts:
  - vhost: ["app.example.com"]
    login_redirect_url: "/app"
    routes:
      - prefix: "/"
        static: "/var/www/app/public/"
        static_index: "login.html"
        tag_proxyauth: true
      - prefix: "/app"
        static: "/var/www/app/secret/index.html"
        required_login: true
login.html
<p>{{ username }}</p>

<form method="POST" action="/auth">
  <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
  <input type="text" name="username">
  <input type="password" name="password">
  <button type="submit">Sign in</button>
</form>

<footer>v{{ proxyauth_version }}</footer>

For a first-time visitor: {{ username }} is left as literal text (nobody to greet), {{ csrf_token }} is filled in with a real token so the login form actually works, {{ proxyauth_version }} resolves regardless of login state.

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.

need_anti_replayboolean
Per-route override for the anti-replay signing plugin. Only has any effect when the server-wide 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.
default: true

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 #

cacheboolean
Whether responses from this route may be cached downstream at all. 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.
default: true
cache_duration_secsinteger
When cache is on, the max-age value sent in Cache-Control: public, max-age=... for this route's static files.
⚠️
Set 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.

yaml
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.

proxyboolean
Enable forwarding through an intermediate proxy.
default: false
proxy_configstring
Address of the intermediate proxy.
format: http://host:port

Load 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.

yaml
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
urlrequired
The backend's URL.
weightinteger
Relative share of traffic — weight 2 vs weight 1 means roughly 2x the requests. -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.

yaml
routes:
  - prefix: "/webhook"
    target: "http://127.0.0.1:9000"
    filters:
      default_allow: false
      allow:
        - method: "POST"
          header:
            X-Webhook-Secret: "expected-value"
default_allowboolean
What happens when no allow condition matches at all.
allowarray
List of condition objects. Fields available per condition: 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.

config.json
"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:

Incrementalrefresh_interval_secs, default 30s
A cheap, indexed query reading only users changed within the last 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.
Fullslower, less frequent
Reads the entire table. The only way to detect a user hard-deleted from the database — the incremental scan alone can't distinguish "unchanged" from "gone," since a deleted row simply isn't there to compare a timestamp against.

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

bash
$ 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:

File-based
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.
Database-backed
ProxyAuth keeps a mutable in-memory mirror of the database for speed. That mirror is refreshed for the one affected user immediately after every write — no overlay needed, since this mirror is supposed to be mutable in the first place.
flowchart TD A(["Enrollment / reset request"]) --> B{"Found in
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.

POST/adm/auth/totp/get
Body{"username": "alice", "password": "..."}
BehaviorFirst-time enrollment for either account type. 409 if a secret already exists — see self-service re-enrollment for the opt-out.
POST/adm/auth/totp/reset
HeaderX-Auth-Token: <token_admin>
Body{"username": "alice"}
BehaviorClears the secret wherever the account actually lives, old secret stops working immediately.

Self-service re-enrollment #

⚠️
A real security trade-off — read this before using it. This is a deliberate, narrow exception, not a general convenience toggle.

/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.

yaml
vhosts:
  - vhost: ["internal-tool.example.com"]
    allow_totp_reenroll: true

What 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.

bash
$ proxyauth stats
output
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
ℹ️
The socket lives at /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

  1. On the source instance, sync export generates a fresh OpenPGP certificate (via Sequoia), encrypts its own private key material with a passphrase derived from that instance's config.json secret, and writes it to key.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 — into data.pgp, addressed to that same certificate.
  2. Both files are transferred to the target instance (out of band — scp, a shared volume, however you'd move any other secret).
  3. On the target instance, sync import uses its own (matching) secret to unlock key.asc's private key, then decrypts data.pgp with it.
  4. 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.
bash — on the source instance
$ proxyauth sync export
Key export Success (secret key encrypted with AppConfig password)
# produces ./key.asc and ./data.pgp
bash — on the target instance, after copying both files to /etc/proxyauth/import/
$ proxyauth sync
⚠️
Both instances need the same 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

prepare [--insecure]
First-time setup — creates the 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

db-add-user --username U [--password P] [--email E...] [--must-change-password]
Creates or updates a user directly in the database. Omitted --password prompts interactively (hidden input). --email is authoritative, not a merge — omitting it entirely clears any email(s) already on file.
db-delete-user --username U
Soft-deletes — the row is kept, marked deleted, so every connected instance's incremental scan picks it up and revokes access right away. Purged permanently later on its own.
db-sync-cache [--force]
Forces an immediate full database read into the local LMDB fallback cache, instead of waiting for the next scheduled scan. Refuses to overwrite a non-empty cache with an empty result unless --force.
db-restore-from-cache [--force]
Re-populates the database from this instance's local cache — for after the database comes back empty by mistake. Never runs automatically. Refuses if the database already has users, unless --force.
db-clear-cache
Wipes the local LMDB fallback cache. The next successful full read repopulates it as usual.

Accounts & access

reset-password --username U [--vhost V]
Emails a single-use password reset link. Both 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.
reset-otp --username U
Clears a user's TOTP secret. See Database-backed TOTP.
users [--list]
Every known account (file and database), its groups/roles, and every route it can currently reach and via which mechanism.
groups [--list]
Every group currently referenced by an account or a route — current members, and which routes list it directly.
roles [--list]
Same as groups, for roles.
routes-audit
What secures each route — a username, a group, a role, or "public"/"open" — from the same decision logic actually enforced at request time, not a re-implementation of it.
check-access --username U
One account's access across every route — ✓/✗ and exactly why.
check-routes
Every route resolved down to the concrete accounts that currently satisfy it — flags a route secured by a group/role no current account actually matches (almost always a typo).

TLS & certificates

certbot renew <vhost|all>
Renews now, via the same native ACME mechanism the periodic certbot_renew: true scan uses. Doesn't require certbot_renew: true to also be set — running this by hand is itself sufficient intent.
certbot check <vhost|all>
Certificate status and days remaining.
certbot new <vhost>
Forces fresh issuance, even if a valid certificate already exists.

Operations

stats
Live stats over the local Unix socket. See Local stats socket.
sync [target]
Imports build constants from /etc/proxyauth/import/ (or target if given). See SSO across instances.

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.

GET/adm/stats
Requiresstats: true in config.json
ReturnsRequests/sec (last, 10s avg, 60s avg), total requests, active sessions, uptime — the same data proxyauth stats reads locally over the Unix socket instead.
GET/adm/stats/sessions
ReturnsPer-token usage counters — which token IDs have been used, how many times, by whom.
GET/adm/logs
ReturnsRecent access-log entries, filterable — see the full wiki for the query parameter reference.
POST/adm/revoke
Body{"token_id": "..."}
BehaviorAdds the token ID to the revocation set checked on every subsequent token verification — a structurally valid, unexpired token stops working immediately. Synced across instances via Redis if configured.
POST/adm/auth/totp/get
AuthUsername + password in the body, not the admin token
BehaviorFirst-time TOTP enrollment — see Database-backed TOTP.
POST/adm/auth/totp/reset
Body{"username": "alice"}
BehaviorClears a user's TOTP secret so they can re-enroll — see Database-backed TOTP.