Docs/Introduction
ProxyAuth documentation

Any app. Any language.
Authentication it never has to implement.

ProxyAuth authenticates and authorizes every request before it reaches your application. The app behind it needs no login page, no session store, no auth library, no framework plugin — it receives an already-verified identity and gets on with its job.

That holds whether it is written in Rust, Go, PHP, Python or something you inherited and cannot modify. Securing an application stops being a project with dependencies to install and integrate, and becomes a few lines in a configuration file.

This reference covers every mechanism it is built on: how a request is matched to a route, how a token is constructed and verified, what each field in config.json and routes.yml does, and the multi-tenant model behind it.

72
documented sections
6
OIDC endpoints
1.3.0
current release
Apache 2.0
license
Getting started

Installation #

One script, twelve steps, on Debian, Ubuntu, Alpine, Arch or Fedora. It compiles ProxyAuth from source on your own machine, verifies what it downloaded before touching it, and runs the result as an unprivileged system account. Everything it does is listed below.

read it first, then run it
$ curl -fsSLO https://proxyauth.app/install.sh
$ less install.sh
$ sudo sh install.sh

The script re-executes itself under sudo if you start it as a normal user, so sh install.sh alone also works. A full transcript is written to /tmp/proxyauth-install.log as it goes.

What it does, in order

StepWhat happensOn failure
1 · detect
Identifies the distribution from /etc/os-release markers. Debian and Ubuntu, Alpine, Arch, and Fedora and its relatives are recognised.
stops before changing anything
2 · dependencies
Installs the build toolchain and libraries through your own package manager — a C compiler, pkg-config, OpenSSL headers, and the PostgreSQL and MariaDB client libraries. Nothing is fetched from outside your distribution's repositories at this step.
stops
3 · Rust
Only if cargo is not already on the machine. Installs it through rustup, the official toolchain installer, into the invoking user's home. An existing Rust installation is used as it is.
stops
4 · system user
Creates a proxyauth system account and group: no home directory, no login shell (nologin), no password. An account of that name that already exists is left untouched.
stops
5 · download
Fetches the source crate over HTTPS from crates.proxyauth.app into /tmp. The file is checked for being non-empty and for actually being a valid gzip archive before anything else looks at it.
deletes the file, stops
6 · checksum
Downloads the published SHA-256 and compares it against the archive. Both values are written to the log so you can check them by hand afterwards.
deletes the file, stops
7 · extract
Unpacks into /tmp/proxyauth_install and locates the Cargo.toml.
stops
8 · compile
cargo install --locked from that source tree — --locked means the exact dependency versions in Cargo.lock, not whatever is newest today. Uses all cores but two, so the machine stays usable. On Alpine, static CRT linking is disabled because the database client libraries are dynamic there.
prints the build log path, stops
9 · directories
Creates the configuration, database and log directories, and gives them to proxyauth with mode 750. See the table below for the full list.
stops
10 · certificate
Generates a self-signed certificate for localhost so the instance can start over TLS immediately. An existing pair at those paths is kept and only has its ownership corrected.
stops
11 · prepare
Runs proxyauth prepare, the binary's own setup command, which finishes the directory layout and permissions.
stops
12 · service
Detects systemd or OpenRC and installs the matching unit, then starts it. Inside a container, this step is skipped and only the binary is installed.
reports, keeps the binary
Built on your machine
What is downloaded is the source crate, not a prebuilt binary — the same archive published on crates.proxyauth.app, verified against its published SHA-256, then compiled locally with the dependency versions pinned in Cargo.lock. You can unpack and read it before step 8 if you want: it is left in /tmp/proxyauth_install for the duration of the run.

Everything it writes

The complete list. Nothing outside these paths is modified.

PathContentsOwner · mode
/usr/local/bin/proxyauthThe compiled binary.root · executable
/etc/proxyauth/Configuration, under config/.proxyauth · 750
/etc/proxyauth/certs/The default certificate and key.proxyauth · 750
/opt/proxyauth/db/Local LMDB stores — revocations, ACME challenges, the user fallback cache.proxyauth · 750
/var/log/proxyauth/proxyauth.log and access.log.proxyauth · 750
/etc/systemd/system/proxyauth.serviceThe service unit, or /etc/init.d/proxyauth under OpenRC.root
/tmp/proxyauth-install.logThe install transcript. Kept after the run.root
/tmp/proxyauth_install/Download and build scratch space. Deleted on exit, success or failure.root

How it runs afterwards

The service unit is deliberately narrow. ProxyAuth does not run as root.

SettingEffectValue
User · Group
The unprivileged account created at step 4 — no home, no shell, no password.
proxyauth
CapabilityBoundingSet
The single capability needed to bind ports below 1024 — 443 and, for ACME, 80. Every other root capability is dropped and cannot be regained. The binary also carries this capability directly via setcap, so it works the same when started by hand.
CAP_NET_BIND_SERVICE
NoNewPrivileges
The process and anything it spawns can never gain privileges, regardless of setuid binaries on the filesystem.
true
Restart
Restarted on failure after a five-second delay.
on-failure

Before you go to production

The install leaves a working instance; three things are then yours to set.

Replace the self-signed certificate

The generated pair is issued for localhost and is there so the service can start. For a real hostname, declare a vhost_cert and let ACME issue a trusted certificate, or drop your own files in place.

Set your own secret and accounts

secret should be 64 random characters or more, generated rather than typed. Add your accounts to users, or point at a database. See Common setups for a configuration matching your case.

Choose a log destination

The shipped example sets log.type to disabled. Set it to local, loki or http — see Logging & observability.

Group membership
Step 9 adds proxyauth to the www-data group, so a static route can read files belonging to an existing web server without changing their permissions. That group exists by default on Debian and Ubuntu; on Alpine, Arch and Fedora, create it first, or adjust the script to the group your web files actually belong to.
Re-running is safe
Each step checks for what it would create: an existing proxyauth account, an existing certificate pair, an existing Rust toolchain are all reused rather than replaced. Running the script again upgrades the binary and leaves your configuration where it is.
Getting started

Quick start #

Three ways to a working instance, all starting from an installed host. Guided asks a few questions and writes the files for you — the fastest start, and a good way to learn their format. The two manual paths give the complete files to copy: plain HTTP for a local machine or a network you already trust, TLS for anywhere a browser will actually reach it.

Three interactive commands write the first vhost, the first account and the login rules for you. Every question has a default, nothing is written before you have seen it, and each command ends by printing what it added — so the next entry can be written by hand.

Run from a root shell
Like every proxyauth subcommand, quickstart reads config.json while still root, then switches to run_user before writing anything — so the files keep the ownership the running server expects. The commands below are shown without a prefix: run them as root.

Three rules the wizard never breaks

RuleWhat it meansEffect
shown before written
Every command ends its questions by printing the exact block it would add and the file it goes to, then asks Write this? [y/N]. The default is no: Enter or n leaves both files byte for byte unchanged.
nothing by surprise
printed again afterwards
Under What was added, so you can write the next one yourself, with the file path. The wizard teaches the format rather than hiding it.
learn the files
routes.yml edited as text
The block is inserted into the file, so your comments, order and formatting stay. The whole result is then parsed with the server's own loader; if it would not load, nothing is written and the parser's error is shown.
comments kept

Every write is preceded by a backup next to the file — routes.yml.bak-20260916-143012 — so a run is always one mv away from being undone.

Answering

  • Defaults are in brackets — backend URL [http://127.0.0.1:8000]. Enter accepts; most runs can be completed on Enter alone.
  • Yes/no takes y, yes, n, no, and o, oui, non. The capital letter in [Y/n] is the default.
  • Menus are numbered; Enter picks 1. Lists are comma separated — an empty answer is an empty list.
  • Passwords are read twice, with hidden input.
  • Colours are off when the output is not a terminal, or with NO_COLOR=1.
Prepare the host

Creates the proxyauth user and group, and the configuration, certificate and database directories with the right ownership.

shell — as root
$ proxyauth prepare

Then set a real secret in /etc/proxyauth/config/config.json — 64 random characters or more — and, for a public hostname, "tls": true, "port": 443 and a letsencrypt.contact_email. See the TLS tab for a complete file.

Create the vhost — quickstart vhost

Six questions, one new vhosts: entry.

shell — as root
$ proxyauth quickstart vhost
QuestionWhat your answer writesDefault
1 · hostnames
vhost: [...]. Several names share one certificate and one policy; the first one names the certificate paths.
required
2 · TLS
Let's Encrypt: vhost_cert under /etc/proxyauth/cert/<name>/ plus certbot_renew: true. Own certificate: vhost_cert with the two paths you give. None: nothing — plain HTTP, local development only.
Let's Encrypt
3 · who talks to it
Browsers: session_cookie: true, tag_csrf_token: true, login_redirect_url set to the application path, logout_redirect_url: "/" — which also makes / the login page protected routes redirect to. Scripts and APIs: session_cookie: false, tag_csrf_token: false.
Browsers
4 · who may sign in
allow_groups; if you leave it empty, allow_users is asked instead. Both empty: a warning, since a vhost naming nobody accepts no login — fill it in later with quickstart vhost-access.
empty
5 · the application
A route with target (the backend URL), its prefix, required_login, and forward_proxy_headers for the real client IP and Host.
http://127.0.0.1:8000 · /app (/ for an API) · yes · yes
6 · the login page
Browsers only: a static route on / with tag_proxyauth: true — so {{ csrf_token }} is filled in — and cache: false. Skipped for an API, whose clients post straight to /auth.
/var/www/login/ · login.html
a run, on Enter almost everywhere
Quickstart — a vhost and its routes
───────────────────────────────────
      Every question has a default. Press Enter to accept it.
      Nothing is written until you confirm at the end.

[1/6] Which hostname does this vhost answer to?
      hostname(s) (comma separated): tool.example.com
[2/6] TLS
        1) Let's Encrypt — ProxyAuth issues and renews it, nothing else to install
        2) Own certificate — you provide the cert and key paths
        3) None — plain HTTP — local development only
      choice [1]:
[3/6] Who talks to this vhost?
      choice [1]:
[4/6] Who may sign in here?
      groups allowed to log in (comma separated): staff
[5/6] The application behind it
      backend URL [http://127.0.0.1:8000]:
      path it is served at [/app]:
      Require a session to reach it? [Y/n]:
      Send the visitor's real IP and Host to the backend? [Y/n]:
[6/6] The login page
      Serve a login page from disk on /? [Y/n]:
      directory [/var/www/login/]:
      index file [login.html]:

The preview — exactly what is written:

/etc/proxyauth/config/routes.yml · under vhosts:
vhosts:
  - vhost: ["tool.example.com"]
    vhost_cert:
      cert: "/etc/proxyauth/cert/tool.example.com/fullchain.pem"
      key: "/etc/proxyauth/cert/tool.example.com/privkey.pem"
    certbot_renew: true
    allow_groups: ["staff"]
    session_cookie: true
    tag_csrf_token: true
    login_redirect_url: "/app"
    logout_redirect_url: "/"

    routes:
      # The login page is public by definition — nobody can sign in
      # through a page that already requires being signed in.
      - prefix: "/"
        static: "/var/www/login/"
        static_index: "login.html"
        tag_proxyauth: true
        cache: false

      - prefix: "/app"
        target: "http://127.0.0.1:8000"
        required_login: true
        forward_proxy_headers: true

The entry is appended at the end of your vhosts: block — above any comment that introduces the next section of the file — or a vhosts: key is created. The command then prints what to do next: routes.yml is reloaded live, and with Let's Encrypt, certbot new followed by one restart so the new certificate is watched.

The login page itself is yours to write — the minimal form is in Gate one app.

Create an account — quickstart users
shell — as root
$ proxyauth quickstart users
QuestionDetailDefault
where it lives
Asked only when databases is configured. The database is shared by every instance; config.json is local. A login is checked against both either way.
database
username
Required.
—
temporary password?
Yes sets must_change_password: the next login lands on the reset page instead of receiving a session. The password is then typed twice, hidden.
yes
groups · roles
Groups gate routes; roles do too and are also forwarded to the backend as X-User-Roles.
empty
email
Where reset-password sends the link. With several addresses, the primary one is asked.
empty

The password is hashed with Argon2id before anything is written. The preview shows the hash shortened; the destination holds it in full.

  • Into the database — through a live connection only, never the local fallback cache; if the database is unreachable nothing is written. An existing account is updated, a soft-deleted one revived. Every instance sees it on its next incremental scan, without a restart. The equivalent db-add-user command is printed, ready for a script.
  • Into config.json — an existing username is refused; only the fields you filled are written. config.json is read at startup: restart for the account to exist.
Decide who may sign in — quickstart vhost-access

Edits the login gate of a vhost declared in a vhosts: group: allow_users, allow_groups, allow_roles and exclude_users.

shell — as root
$ proxyauth quickstart vhost-access
  1. Which vhost? A menu of the hosts in your groups. What is in force right now is printed first, resolved the way the server resolves it, with a warning if nobody can sign in today.
  2. Who gets in? Groups, roles, usernames — combined as an OR. Leaving all three empty closes the vhost to every login, and is confirmed separately, with no as the default.
  3. Any exception? exclude_users beats every rule above, even a direct username match.
what it sets on the vhost
allow_groups: ["staff"]
allow_roles: ["admin"]
exclude_users: ["contractor-temp"]

The four keys already on that entry are replaced, not merged — block-style lists written by hand included, with their - value lines — and the new keys go just above the entry's routes:. Everything else on the entry stays as it was. routes.yml reloads live: the rule is in force when the command returns.

Issue the certificate and start
shell — as root
$ proxyauth certbot new tool.example.com
$ systemctl enable --now proxyauth
$ systemctl restart proxyauth        # once, so the new certificate is watched

At startup, read the output: an unknown key warning names a misspelt setting, and the session renewal line shows the policy in force — see Startup checks.

Verify
shell — as root
$ proxyauth routes-audit
$ proxyauth check-access --username alice
$ curl -I https://tool.example.com/app      # 302 to / without a session

routes-audit and check-access call the same decision function the proxy enforces, so what they print is what happens.

Optional — keep sessions alive

The wizard does not set up session renewal. Add it to config.json, or to the vhost in routes.yml, and restart:

config.json
{
  "session_refresh_interval_secs": 900,
  "session_refresh_grace_secs": 30
}

When something goes wrong

MessageWhat to do
The generated routes.yml does not parseThe existing file already has a problem, or the combination does not load. Nothing was written; fix the file by hand and run again.
No vhosts: entry declares …The host only exists on a top-level route. Move it into a vhosts: group, or create one with quickstart vhost.
Database is not reachableCheck the databases block and connect_timeout_secs. The fallback cache is never written to.
undo a runmv /etc/proxyauth/config/routes.yml.bak-<timestamp> /etc/proxyauth/config/routes.yml
Scope
Session cookies always carry Secure, HttpOnly and SameSite=Strict, so a browser stores them over plain HTTP only on localhost, which every major browser exempts. This path is therefore for local development and for API clients, which do not use cookies at all. For a real hostname, use the TLS tab — it takes one extra command.
Prepare the host

Creates the proxyauth system user and group, and the configuration and certificate directories with the correct ownership and permissions.

shell
$ sudo proxyauth prepare
Write config.json

The minimum viable configuration: a signing secret, a token lifetime, one account. Everything else has a working default.

/etc/proxyauth/config/config.json
{
  "secret": "a-random-value-of-at-least-64-characters-generated-not-typed",
  "token_expiry_seconds": 3600,
  "host": "127.0.0.1",
  "port": 8080,
  "tls": false,
  "session_cookie": true,
  "users": [
    {
      "username": "alice",
      "password": "changeme",
      "roles": ["admin"]
    }
  ]
}

The plaintext password is hashed with Argon2id in place on first startup — it never stays readable in the file.

Write routes.yml

One vhost, one login page served from disk, one protected application behind it.

/etc/proxyauth/config/routes.yml
vhosts:
  - vhost: ["localhost"]
    login_redirect_url: "/app"
    redirect_login_url: "/"
    routes:
      - prefix: "/"
        static: "/var/www/app/public/"
        static_index: "login.html"
        tag_proxyauth: true
        cache: false

      - prefix: "/app"
        target: "http://127.0.0.1:8000"
        required_login: true
Start the service
shell
$ sudo systemctl enable --now proxyauth
$ curl -I http://localhost:8080/
Verify

Check what each route is actually secured by, and which accounts can reach it — read from the same decision logic enforced at request time, not a re-implementation of it.

shell
$ sudo proxyauth routes-audit
$ sudo proxyauth check-access --username alice

ProxyAuth issues and renews its own Let's Encrypt certificates natively — there is no separate certbot daemon to install, and no renewal cron to write. What follows produces a working HTTPS instance with automatic renewal already on.

Before you start
Two prerequisites, both outside ProxyAuth: an A or AAAA record for app.example.com pointing at this host, and ports 443 and 80 reachable from the internet with nothing else bound to them. You do not need to configure port 80 — ProxyAuth binds it itself, automatically, as soon as tls is on and a route sets certbot_renew. It answers ACME challenges there and redirects everything else to HTTPS. See Let's Encrypt & ACME.
Prepare the host

Same as the plain-HTTP path. Binding port 443 is why the command needs root: privileges are dropped to run_user immediately after the listeners are bound.

shell
$ sudo proxyauth prepare
Write config.json

tls: true and port 443. host takes an array, so one instance binds IPv4 and IPv6 with one listener each. Declaring both means the ACME challenge is answered whichever family Let's Encrypt resolves the domain to.

/etc/proxyauth/config/config.json
{
  "secret": "a-random-value-of-at-least-64-characters-generated-not-typed",
  "token_expiry_seconds": 3600,
  "host": ["0.0.0.0", "::"],
  "port": 443,
  "tls": true,
  "session_cookie": true,
  "max_age_session_cookie": 3600,
  "csrf_token": true,
  "run_user": "proxyauth",
  "letsencrypt": {
    "contact_email": "ops@example.com",
    "renew_before_days": 30
  },
  "users": [
    {
      "username": "alice",
      "password": "changeme",
      "roles": ["admin"]
    }
  ]
}
Write routes.yml with the certificate declared

The paths in vhost_cert are where ProxyAuth will write the certificate it obtains — they do not need to exist yet. certbot_renew: true is what puts this vhost into the periodic renewal scan.

/etc/proxyauth/config/routes.yml
vhosts:
  - vhost: ["app.example.com"]

    # ── TLS ──────────────────────────────────────────────
    vhost_cert:
      cert: "/etc/proxyauth/cert/app.example.com/fullchain.pem"
      key: "/etc/proxyauth/cert/app.example.com/privkey.pem"
    certbot_renew: true

    # ── session policy ───────────────────────────────────
    session_cookie: true
    tag_csrf_token: true
    login_redirect_url: "/app"
    logout_redirect_url: "/"

    routes:
      - prefix: "/"
        static: "/var/www/app/public/"
        static_index: "login.html"
        tag_proxyauth: true
        cache: false

      - prefix: "/app"
        target: "http://127.0.0.1:8000"
        required_login: true
        forward_proxy_headers: true
        headers:
          Strict-Transport-Security: "max-age=63072000; includeSubDomains"
Issue the first certificate

Renewal is automatic from here on; the very first issuance is the one explicit step. certbot new forces issuance even if something already exists at those paths.

shell
$ sudo proxyauth certbot new app.example.com
$ sudo proxyauth certbot check app.example.com
Start and verify
shell
$ sudo systemctl enable --now proxyauth
$ curl -I https://app.example.com/
$ sudo proxyauth routes-audit
$ sudo proxyauth check-access --username alice

What happens at renewal

An hourly scan renews any certificate on a certbot_renew: true vhost once it has 30 days or fewer left — both thresholds configurable under Let's Encrypt & ACME — and the new file is picked up without a restart. Two details make that reliable:

  • Hot-reload watches the certificate's containing directory, not the file. A file-level watch dies the moment a renewal replaces the file's inode, which is exactly what an atomic-rename renewal does.
  • ACME challenge handling sits ahead of every gate in the request lifecycle — including maintenance mode. Putting a vhost into maintenance can never prevent its certificate from renewing.
One restart, once
Certificate paths are watched from startup. A vhost added to routes.yml after the server came up needs one restart to register its watcher — after that, every renewal for it is picked up live, indefinitely, with no further restart.
Next
Adding a second domain is one more vhosts: entry with its own vhost_cert. The right certificate is selected per connection via SNI. Test against Let's Encrypt staging first by setting letsencrypt.directory_url — production allows only a handful of certificates per registered domain per week.
Note
config.json is read once at startup and needs a restart to take effect. routes.yml is reloaded live, including every per-vhost override. What lives in which file determines whether a change needs a restart — see Concepts.
Getting started

Concepts & vocabulary #

Six terms used consistently throughout this documentation. Getting them straight makes the rest of the reference read much faster.

TermMeaning
vhostA hostname, or set of hostnames, that a group of routes answers to. The unit of multi-tenancy: TLS certificate, session policy, CSRF, SMTP and login rules are all scoped here.
routeA prefix or regex, plus exactly one content source — a proxied target or a static path on disk. The unit that a request is ultimately matched to.
backendWhatever sits behind a proxied route. One target, or several weighted backends with failover.
tokenThe encrypted credential issued at login. Carried as a bearer token, or as a session_token cookie when session_cookie is on.
middlewareAnything applied to a request or response between matching and forwarding: access control, CSRF, filters, compression, tag substitution, header injection.
relying partyOIDC terminology. The backend receiving an id_token from ProxyAuth acting as an OpenID Connect provider.

Where each setting lives

Three scopes, from broadest to narrowest. A narrower scope always overrides a broader one. Every reference section in this documentation is marked with the file and the level its fields belong to, so you never have to infer it from context.

ScopeFileReloadTypical use
globalconfig.jsonrestartListeners, secrets, rate limits, database, logging
vhostroutes.ymlliveSessions, CSRF, TLS, SMTP, login policy, OIDC, maintenance
routeroutes.ymlliveAccess control, caching, compression, headers, filters
Getting started

Release highlights — v1.3.0 #

1.3.0 is about sessions: how they start, how they are kept alive, and how they end. Browsers are sent to the login page instead of receiving a bare 401, active sessions are renewed without signing in again, and every revocation now carries an end date and is cleaned up in memory, LMDB and Redis alike.

Sessions
A session in use gets a fresh token at a set interval, in a new Set-Cookie. The replaced token keeps working for a short grace period, then is refused.
Sessions
A renewed token names the token it replaced. As soon as the new one is used, the old one is refused — without waiting for the end of the grace period.
API
API sessions get a refresh token with the same token id as the access token. POST /refresh_token rotates both, and a reused refresh token revokes the session.
Sessions
A browser opening a required_login route without a valid session is sent to the login page with a 302. API calls keep their 401.
Sessions
session_max_lifetime_secs ends a session a fixed time after login, renewals included.
Revocation
Every revocation records when it was made and when it can be dropped. Nothing is kept forever, and the purge runs with or without Redis.
Revocation
Every Redis key of a revocation expires with it, and keys left behind by earlier versions are removed instead of being read as permanent.
Caching
A response to a signed-in visitor is never stored by a shared cache, and one carrying a session token is never stored at all.
Operations
A misspelt key in config.json or routes.yml is reported with the setting you most likely meant.
Operations
When ProxyAuth rewrites config.json at startup, every setting is written back — none are dropped any more.

Added in 1.3.0

Field or behaviorSectionSummary
session_refresh_interval_secsSession renewalRenew a session token once it is older than this. Global, per vhost or per route.
session_refresh_grace_secsSession renewalHow long a replaced token keeps working. Default 30 seconds.
session_max_lifetime_secsSession renewalHard end of a session, counted from the original login.
predecessor retirementSession renewalA renewed token carries the id of the token it replaced; that token is refused two seconds after the new one is first used, and at once on /logout.
refresh_token_expiry_secondsRefresh tokensLifetime of the refresh token returned by /auth in JSON mode. 0 (default) turns the feature off. Global, per vhost or per route.
POST /refresh_tokenRefresh tokensExchanges a refresh token for a new pair on the same token id, with reuse detection.
redirect_login_urlLogin redirectWhere a browser without a valid session is sent. Falls back to logout_redirect_url. Also accepted as login_page_url.
session_cookie_agePer-vhost overridesAccepted as an alias of max_age_session_cookie.
unknown-key warningsStartup checksKeys nothing reads are reported at startup, with a suggestion.
renewal summaryStartup checksThe effective renewal policy is printed at startup, with a warning when it can never apply.

Changed in 1.3.0

Behavior changes worth reading before upgrading. None of them requires a configuration change.

AreaBeforeNow
/adm/revokeAn exp in the future did not revoke the token until that date — and the purge deleted the entry at that very date, so the token was never refused. No exp meant kept forever.The token is refused immediately. exp is accepted and ignored: the record is kept until now + token_expiry_seconds, then purged. See Revocation.
/logoutRevocation kept forever.Kept until the token's own expiry.
revocation purgeRan only when redis was configured. Redis action keys never expired and were re-read as permanent revocations once their value expired.Runs on every instance, and cleans memory, LMDB and Redis. Stale Redis keys are removed. Entries kept forever by an earlier version get an end date on first read.
signed-in visitor on /Always forwarded to login_redirect_url, even when / is a public page.Forwarded only from the login page itself. See Login redirect.
signed in, not allowed401 carrying the text 403 Forbidden, and the session cleared on one of the two proxy paths.A real 403; the session is kept.
cachingA signed-in visitor's page followed the route's public caching.private, no-cache for a signed-in visitor, private, no-store when the response carries a session cookie. See Caching.
short lifetimes/auth failed on every login when token_expiry_seconds or max_age_session_cookie was under 60 seconds.Logins work; the cookie simply never outlives the token.
OIDC id_tokenSigning panicked in the worker when the binary was built without a jsonwebtoken crypto provider — POST /oidc/token answered nothing at all.The provider is selected in Cargo.toml, and a probe token is signed at startup so such a build never starts. See Signing key setup.
config.json rewriteSettings such as smtp, trust_proxy_forward_for, ip_blocklists, run_user or max_body_size were dropped when ProxyAuth rewrote the file at startup.Every setting is written back.
Check config.json after upgrading
If an earlier version rewrote your config.json — after hashing a plaintext password, sorting users or generating token_admin — it may already have lost some settings. Compare it with your backup, and read the startup output of the first 1.3.0 start.

Added in 1.2.7

Field or behaviorSectionSummary
if_vhostNarrowing a routeRestrict a route to some of the hosts its group declares, validated at startup.
mixed vhost listsWildcard vhostsA list holding both literal names and patterns now yields to a host declared explicitly elsewhere.

Added in 1.2.5

Field or behaviorSectionSummary
streamingStreamed responsesRelay a response as it arrives instead of collecting it in memory first.
backend_timeoutBackend timeoutHow long a proxied request may take before it is abandoned.
vhost: ["*.example.com"]Wildcard vhostsA vhost entry may be a pattern covering one extra label.
multi-SAN certificatesLet's Encrypt & ACMENames sharing a vhost_cert land on one certificate instead of overwriting each other.
manual DNS-01Let's Encrypt & ACMEWildcard certificates, validated by a TXT record you create.

Added in 1.2.1 and later

FieldSectionSummary
redirect_protect.targetMaintenance modeProxy a blocked request to another service instead of serving a file from disk.
redirect_protect.pathsSession-checked pathsPath-scoped gates that verify a session against the backend itself.
redirect_protect.redirect_urlSession-checked pathsA real 303 See Other destination when a path check fails.
hidden_blocksHidden blocksStrip one HTML element from a response instead of blocking the whole path.
letsencryptLet's Encrypt & ACMERenewal thresholds, ACME directory and account persistence, under their own config key.
strict_mtlsClient certificatesChoose whether a certificate error answers 502 or connects without client authentication.

Common setups
Seven complete configurations covering what people actually deploy. Each one is a working pair of files, not a fragment — copy it, change the hostnames and the backend addresses, and it runs. Read the one that matches your case; you do not need the rest.
Formatconfig.json + routes.yml
Assumesa prepared host
Referencelinked from each recipe
Common setups

Which setup am I? #

Pick by what sits behind the proxy and who talks to it.

You haveYour clients areGo to
One web app with no login of its ownBrowsersGate one app
An HTTP APIScripts, services, mobile appsProtect an API
A public site with an admin areaBrowsers, mostly anonymousPublic site, private admin
Several apps on several domainsDifferent user populationsSeveral domains
An app that already speaks OIDCBrowsersHand identity to the app
Several instances of one appAnyoneSpread the load
A deployment window coming up—Close the doors

The three decisions every setup makes

Whatever you pick, you are answering the same three questions. Knowing that makes the recipes below read as variations rather than seven unrelated things.

DecisionWhat it changesSet with
How does a client prove who it is?
A browser wants a cookie it does not have to think about. A script wants a token it sends in a header. One instance can do both, on different vhosts.
session_cookie
Who is allowed to log in here?
Separate from what they can reach afterwards. A vhost grants no login at all until you name someone.
allow_users
allow_groups
What does each route need?
Whether a session is required at all, and if so which accounts get through.
required_login
groups · roles
Name who may sign in
A vhost grants login access explicitly. allow_users, allow_groups and allow_roles combine as an OR, and a vhost that names none of them accepts no logins — so a new domain added to a live instance is closed until you decide who belongs on it, rather than inheriting everyone who exists anywhere in the system.

Every recipe below sets one of the three. Routes with no vhost are outside this mechanism entirely: with no vhost resolved there is nothing to authorize against, and login proceeds on credentials alone. See Login authorization.
Common setups

Gate one app behind a login page #

The most common deployment by a wide margin: an internal tool, a dashboard, a legacy application with no authentication of its own — put a login in front of it without touching its code.

/etc/proxyauth/config/config.json
{
  "secret": "generate-64-random-characters-do-not-type-them-by-hand",
  "token_expiry_seconds": 28800,
  "host": ["0.0.0.0", "::"],
  "port": 443,
  "tls": true,

  "session_cookie": true,
  "max_age_session_cookie": 28800,
  "csrf_token": true,

  "log": { "type": "local" },
  "letsencrypt": { "contact_email": "ops@example.com" },

  "users": [
    { "username": "alice", "password": "temporary", "groups": ["staff"] },
    { "username": "bob",   "password": "temporary", "groups": ["staff"] }
  ]
}
/etc/proxyauth/config/routes.yml
vhosts:
  - vhost: ["tool.example.com"]

    vhost_cert:
      cert: "/etc/proxyauth/cert/tool.example.com/fullchain.pem"
      key: "/etc/proxyauth/cert/tool.example.com/privkey.pem"
    certbot_renew: true

    # Who may log in on this vhost at all.
    allow_groups: ["staff"]

    session_cookie: true
    login_redirect_url: "/app"
    logout_redirect_url: "/"

    routes:
      # The login page, public by definition — nobody can log in
      # through a page that requires being logged in.
      - prefix: "/"
        static: "/var/www/login/"
        static_index: "login.html"
        tag_proxyauth: true
        cache: false

      # The application itself.
      - prefix: "/app"
        target: "http://127.0.0.1:8000"
        required_login: true
        forward_proxy_headers: true

The login page

A plain HTML file. tag_proxyauth: true on that route is what fills the CSRF field in; without it the tag stays as literal text and every submission is rejected.

/var/www/login/login.html
<form method="POST" action="/auth">
  <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
  <input type="text" name="username" autocomplete="username">
  <input type="password" name="password" autocomplete="current-password">
  <button type="submit">Sign in</button>
</form>

What to change

  • tool.example.com and the two certificate paths — three places.
  • http://127.0.0.1:8000 to wherever your app listens.
  • The accounts. Passwords written in plaintext are hashed in place with Argon2id on first startup.
Then
Add TOTP with one line, login_via_otp: true on the vhost. Move accounts out of the file into a database when there are more than a handful. Give people a way to reset their own password with smtp and page_change_password.
Common setups

Protect an API #

No browser, no cookie, no login page. A client posts credentials once to /auth, gets a token back as JSON, and sends it on every subsequent request.

/etc/proxyauth/config/config.json
{
  "secret": "generate-64-random-characters-do-not-type-them-by-hand",
  "token_expiry_seconds": 3600,
  "host": ["0.0.0.0"],
  "port": 443,
  "tls": true,

  "session_cookie": false,
  "csrf_token": false,

  "ratelimit_auth":  { "requests_per_second": 2,   "burst": 5,   "block_delay": 5000 },
  "ratelimit_proxy": { "requests_per_second": 200, "burst": 400, "block_delay": 200 },

  "log": { "type": "local" },
  "letsencrypt": { "contact_email": "ops@example.com" },

  "users": [
    { "username": "ci-runner",  "password": "temporary", "roles": ["ci"] },
    { "username": "partner-01", "password": "temporary", "roles": ["partner"] }
  ]
}
/etc/proxyauth/config/routes.yml
vhosts:
  - vhost: ["api.example.com"]

    vhost_cert:
      cert: "/etc/proxyauth/cert/api.example.com/fullchain.pem"
      key: "/etc/proxyauth/cert/api.example.com/privkey.pem"
    certbot_renew: true

    allow_roles: ["ci", "partner"]
    session_cookie: false

    routes:
      # Read-only, open to every authenticated client.
      - prefix: "/v1"
        target: "http://127.0.0.1:8000"
        required_login: true
        allow_methods: ["GET", "HEAD"]
        forward_proxy_headers: true

      # Writes, restricted to CI and coming only from the build network.
      - prefix: "/v1/deploy"
        target: "http://127.0.0.1:8000"
        required_login: true
        roles: ["ci"]
        allow_ips: ["10.20.0.0/16"]
        allow_methods: ["POST"]

      # Health check: no auth, no logging, no caching.
      - prefix: "/healthz"
        target: "http://127.0.0.1:8000"
        required_login: false
        log: false
        cache: false

How a client uses it

shell
$ TOKEN=$(curl -s -X POST https://api.example.com/auth \
      -d 'username=ci-runner&password=...' | jq -r .token)

$ curl https://api.example.com/v1/builds -H "Authorization: Bearer $TOKEN"
Why these settings
session_cookie: false is what makes /auth answer with JSON instead of setting a cookie and redirecting. csrf_token: false follows from it — CSRF protects a browser session, and there is no browser here. The two rate limits are separate on purpose: a burst of bad credentials against /auth cannot starve real API traffic, and the reverse.
Note
prefix: "/v1/deploy" is matched before /v1 regardless of the order they appear in — longest prefix first. You do not have to order them by hand. See Routing.
Common setups

Public site, private admin #

One domain, one backend, two audiences. Everyone reads the site; a few people reach /admin. The only difference between the two routes is required_login.

/etc/proxyauth/config/routes.yml
vhosts:
  - vhost: ["www.example.com", "example.com"]

    vhost_cert:
      cert: "/etc/proxyauth/cert/example.com/fullchain.pem"
      key: "/etc/proxyauth/cert/example.com/privkey.pem"
    certbot_renew: true

    allow_groups: ["editors"]
    session_cookie: true
    login_redirect_url: "/admin"
    redirect_login_url: "/login"

    headers:
      Strict-Transport-Security: "max-age=63072000; includeSubDomains"

    routes:
      # Everything public. Compressed, cached, no session needed.
      - prefix: "/"
        target: "http://127.0.0.1:8000"
        required_login: false
        forward_proxy_headers: true
        compression:
          enabled: true
          algorithm: "br"
          min_size: 1024
        cache: true
        cache_duration_secs: 600

      # The login form, served from disk.
      - prefix: "/login"
        static: "/var/www/login/"
        static_index: "login.html"
        tag_proxyauth: true
        cache: false

      # The admin area. Longest prefix wins, so this beats "/" above.
      - prefix: "/admin"
        target: "http://127.0.0.1:8000"
        required_login: true
        groups: ["editors"]
        cache: false
        headers:
          X-Frame-Options: "DENY"
Two mechanics at work here
The vhost list. One entry covers as many hostnames as you give it. Matching is case-insensitive and strips the port, so Example.com:443 and example.com resolve to the same vhost; www. is a distinct hostname and is listed alongside.

Header inheritance. A group's headers is merged with each route's own rather than replaced, so /admin serves the group's HSTS and its own X-Frame-Options together. A route redefining a key the group also sets wins on that key alone.
Why cache: false here
The login page is rendered per visitor — its {{ csrf_token }} is generated for that request. cache: false sends no-store so each visitor receives their own copy. Every other route in this recipe stays cacheable. See Caching.
Common setups

Several domains on one instance #

This is what ProxyAuth is built around. Each vhost gets its own certificate, its own session policy, its own login rules, its own mail server — sharing nothing but the process.

/etc/proxyauth/config/routes.yml
vhosts:
  # ── Internal tool: strict. TOTP required, short sessions. ──
  - vhost: ["internal.example.com"]
    vhost_cert:
      cert: "/etc/proxyauth/cert/internal.example.com/fullchain.pem"
      key: "/etc/proxyauth/cert/internal.example.com/privkey.pem"
    certbot_renew: true

    allow_groups: ["staff"]
    exclude_users: ["contractor-temp"]

    session_cookie: true
    login_via_otp: true
    max_age_session_cookie: 1800
    session_refresh_interval_secs: 300
    login_redirect_url: "/app"
    redirect_login_url: "/"

    routes:
      - prefix: "/"
        static: "/var/www/internal-login/"
        static_index: "login.html"
        tag_proxyauth: true
        cache: false
      - prefix: "/app"
        target: "http://127.0.0.1:8000"
        required_login: true

  # ── Customer portal: its own mail server, longer sessions. ──
  - vhost: ["portal.customer.tld"]
    vhost_cert:
      cert: "/etc/proxyauth/cert/portal.customer.tld/fullchain.pem"
      key: "/etc/proxyauth/cert/portal.customer.tld/privkey.pem"
    certbot_renew: true

    allow_groups: ["customers"]

    session_cookie: true
    max_age_session_cookie: 86400
    login_redirect_url: "/"
    page_change_password: "https://portal.customer.tld/new-password.html"
    cors_origins: ["https://portal.customer.tld"]

    smtp:
      host: "smtp.customer.tld"
      port: 587
      username: "noreply@customer.tld"
      password: "..."
      from: "Portal <noreply@customer.tld>"
      timeout_secs: 10

    routes:
      - prefix: "/"
        target: "http://127.0.0.1:9000"
        required_login: true
        forward_proxy_headers: true
Why this works
Both vhosts declare a route at /, and each request reaches its own. Matching is vhost-aware end to end, so the two entries are independent: you can add a third domain, reorder them, or split them across files without any of them affecting the others. See Routing.
Note
exclude_users beats every allow rule. contractor-temp stays out of the internal tool even while remaining in the staff group and keeping access to everything else.
Common setups

Hand identity to an app that speaks OIDC #

Grafana, Nextcloud, GitLab — anything with its own user model and native OIDC support. Instead of gating it from outside, ProxyAuth becomes its identity provider, and the app manages its own sessions and permissions.

/etc/proxyauth/config/routes.yml
vhosts:
  - vhost: ["grafana.example.com"]

    vhost_cert:
      cert: "/etc/proxyauth/cert/grafana.example.com/fullchain.pem"
      key: "/etc/proxyauth/cert/grafana.example.com/privkey.pem"
    certbot_renew: true

    allow_groups: ["staff"]

    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"
      logout_redirect_uris:
        - "https://grafana.example.com/login"
      scopes: ["openid", "profile", "email"]

    routes:
      # No required_login here — the backend decides.
      - prefix: "/"
        target: "http://127.0.0.1:3000"

Generate the signing key once, before starting, and set the app up against the six endpoints:

shell
$ openssl genrsa -traditional -out /etc/proxyauth/oidc/signing_key.pem 2048
$ chmod 600 /etc/proxyauth/oidc/signing_key.pem
$ chown proxyauth /etc/proxyauth/oidc/signing_key.pem
$ curl https://grafana.example.com/.well-known/openid-configuration
Different rules apply here
On an OIDC vhost, ProxyAuth's own enforcement steps aside entirely — required_login, CSRF, and route-level groups/roles all stop applying, and /auth and /logout proxy through to the backend like any other path. Leave required_login unset — the backend owns that decision here. allow_groups still applies, because that gate runs at login inside /oidc/authorize. Full detail in OIDC provider.
Common setups

Spread the load across several backends #

Replace target with backends. Weighted round-robin, automatic failover, and a cooldown on anything that times out.

/etc/proxyauth/config/routes.yml
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

    allow_groups: ["staff"]
    session_cookie: true

    routes:
      - prefix: "/"
        required_login: true
        forward_proxy_headers: true
        backends:
          # Two equal workers.
          - url: "http://10.0.0.1:8000"
            weight: 1
          - url: "http://10.0.0.2:8000"
            weight: 1
          # A bigger machine, twice the share.
          - url: "http://10.0.0.3:8000"
            weight: 2
          # Cold standby: no traffic unless all three above are down.
          - url: "http://10.0.9.9:8000"
            weight: -1
Note
A backend that times out goes on a short cooldown rather than being retried on every subsequent request — otherwise a machine that is genuinely down attracts a thundering herd of doomed attempts. See Load balancing.
Common setups

Close the doors for a deployment #

Add one block to a vhost, reload, and everyone but you gets a maintenance page. Remove it afterwards. routes.yml reloads live, so neither step needs a restart.

/etc/proxyauth/config/routes.yml
vhosts:
  - vhost: ["app.example.com"]

    redirect_protect:
      allow_ip:
        - "203.0.113.42"     # you
        - "10.0.0.0/8"       # the office
      path: "/var/www/maintenance/index.html"

    # ...the rest of the vhost, unchanged
    allow_groups: ["staff"]
    routes:
      - prefix: "/"
        target: "http://127.0.0.1:8000"
        required_login: true

Blocked visitors get the file at path with a 503. It is read fresh on every request, never cached, so editing the page takes effect immediately. Leave allow_ip empty for a genuine maintenance-for-everyone — there is no need to spell out a catch-all.

Renewal keeps working
ACME challenge handling runs ahead of this gate in the request lifecycle, so certificates continue to renew normally for as long as maintenance mode stays on — including across a long planned outage.
Going further
Point target at a status page hosted elsewhere instead of serving a local file, gate individual sub-paths on a real backend session with paths, or hide one element of a page rather than blocking it with hidden_blocks.

Architecture

Request lifecycle #

The order in which checks run is part of the contract. It tells you what each mechanism protects, and where in the chain a given setting takes effect.

flowchart TD A(["Request arrives"]) --> B{"IP on a
blocklist?"} B -->|yes| R1(["403 — never reaches routing"]) B -->|no| C{"ACME challenge
path?"} C -->|yes| R2(["Served — certificate renewal
is never gated"]) C -->|no| L{"Login page, and
already signed in?"} L -->|yes| R9(["303 to login_redirect_url"]) L -->|no| D["Match a route
see Routing"] D -->|no match| R3(["404"]) D -->|matched| E{"redirect_protect
allows this IP?"} E -->|no| R4(["Maintenance response"]) E -->|yes| F{"Rate limit
exceeded?"} F -->|yes| R5(["429"]) F -->|no| G{"required_login
and no valid session?"} G -->|"yes — page"| R6(["302 to the login page"]) G -->|"yes — API call"| R6b(["401"]) G -->|no| S["Session renewal
when due"] S --> H{"CSRF required
and invalid?"} H -->|yes| R7(["403"]) H -->|no| I{"Route access control
username / groups / roles"} I -->|denied| R8(["403"]) I -->|allowed| J["Filters, then forward
to target or serve static"] J --> K["Response middleware:
tags, hidden_blocks,
headers, compression,
session cookie and cache policy"] K --> Z(["Response to client"]) 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,C,L,E,F,G,H,I decision class D,J,K,S step class Z,R2,R9 success class R1,R3,R4,R5,R6,R6b,R7,R8 error
Simplified — response middleware only runs on routes that opted into it. On a public route, a valid session cookie is also checked for renewal, and an invalid one is cleared; the page is served either way.

Properties this ordering provides:

  • IP blocklists run before everything. A blocked address never reaches route matching, authentication, or CSRF — so nothing downstream can be used to bypass it.
  • Certificate renewal is never gated. ACME challenge handling sits ahead of maintenance mode, so putting a vhost into maintenance cannot lock you out of renewing its certificate.
  • Only the login page forwards a signed-in visitor. Every other page, public ones included, is served normally. See Login redirect.
  • A missing session is not the same as a refused one. No valid session: the page is sent to the login page, an API call gets 401. A valid session without access: 403, and the session stays valid.
  • Admin endpoints are not rate-limited. /adm/* is protected by token_admin alone. Treat that token as a root credential.
Architecture

Routing & route matching #

Every incoming request is matched against routes.yml in a fixed order. Regex routes are tried in file order; prefix routes are tried longest first, whatever their position. Knowing which rule applies to which kind is all it takes to predict where a request lands.

Evaluation order

  1. Exact vhosts before wildcard vhosts. Matching runs in two passes: the first considers only routes naming the requested host literally, the second adds routes matching it through a pattern. A host named explicitly anywhere in routes.yml therefore always wins over a pattern that would also cover it. Catch-all routes — an empty vhost — sit in the first pass and keep the rank they have always had.
  2. Regex routes — any route declaring a regex field — in the order they appear in routes.yml. First match wins, the same way nginx tries location ~ pattern blocks in file order.
  3. Then plain-prefix routes, longest prefix first. A route matching /api/v2 is tried before one matching /api, regardless of which is listed first — the more specific route always gets first refusal.
  4. / is always tried last among prefix routes, whatever its listed position. It is the most generic possible prefix, so it only catches what nothing more specific claimed.

Within that order, a route only matches if its vhost list also matches the request's Host header — or if the route declares no vhost at all, making it a catch-all. See Virtual hosts.

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

Prefix matching, precisely

A request path matches a route's prefix if it is an exact match, or if it starts with the prefix followed by /. So 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.

Design
File order does not affect the result for prefix routes. On a multi-tenant instance, several vhosts commonly declare their own route at / — each one its home page. Longest-prefix-first combined with end-to-end vhost matching means each request reaches its own vhost's route regardless of where that route sits in the file. You can group, reorder or reformat routes.yml freely without changing behavior.
Architecture

Tokens & sessions #

Everything cryptographic about a token — key derivation, sealing, the signature, the optional obfuscation pass — lives in the zerocrypt library. ProxyAuth's job is to map its configuration and its build constants onto one process-wide vault at startup. That vault is immutable afterwards and Send + Sync, so every worker shares one instance with no lock and no cache on the request path.

Two secrets, not one

The vault is built from your secret plus a build key derived from constants baked into the binary at compile time. Both are required — this is what makes sync export/sync import necessary for a multi-node deployment rather than simply copying secret across.

InputWhat it contributesSource
secretconfig.json
The operator secret. Passed to the vault builder directly.
yours
build_hk32 bytes
The one input carrying real entropy — drawn from the OS CSPRNG by build.rs when the binary was compiled. Validated before anything is adopted, so an instance always runs with a build secret at full length.
build-time
build_time · build_rand
build_epochdate · field orderingmixed
These provide the binding rather than the entropy. Any difference in a binary's build constants yields a different key, so tokens are valid only on the deployment that issued them — and across a fleet, only where the keystore has been shared on purpose.
build-time

All of it is folded into a single 32-byte key through BuildKey::derive, under the domain string proxyauth.token.v1 — so two callers feeding identical material into the same library still get unrelated keys. The parts are length-prefixed inside the derivation, so no rearrangement of them can collide with another.

Design
The binding is applied once, in key derivation, rather than recomputed on every signature. A token is therefore verified at the cost of the cryptography alone, while remaining tied to the exact binary that issued it.

The obfuscation pass

fast decides whether an extra obfuscation pass runs on top, keyed by a build seed. Read once at startup rather than on every request.

ValueBehaviorTrade-off
fast: falsedefault
Applies the obfuscation factor, derived from BUILD_SEED2. That seed must land in the range 10..=99 — a binary built with anything else refuses to start, with the offending value named.
a higher work factor per verification
fast: true
Skips the pass entirely.
throughput

The verification cache

The vault keeps up to 50 000 successfully verified tokens. The bound is deliberate: the cache key is a live token, and an unbounded map keyed by attacker-supplied strings is a memory-exhaustion primitive. At roughly 300 bytes an entry that is a few megabytes, and a deployment with more live sessions than that sees a lower hit rate rather than unbounded growth.

Sizing
One cache per process, and num_instances does not start processes — every instance runs in the same one, so the figure above is the whole-host bound rather than a per-instance one. A second ProxyAuth process, started separately, would of course have its own.

What a token carries

Besides the username, the token id and the expiry, a token carries what ProxyAuth reads back about the session: the account's index, the time the token was issued, the time the session began and, for a token issued by a renewal, the id of the token it replaced. They drive session renewal, session_max_lifetime_secs and the early retirement of a replaced token; the session start is carried over from token to token. Tokens issued before 1.3.0 carry only the index and still validate.

What the cache does not cover

This is the part that matters operationally. The cache stores only successful verifications and re-checks expiry on every hit. It knows nothing about revocation, user lookup, or the expiry policy — and those three are re-checked after every vault call, cached or not:

Vault verification

Decrypt, check the signature, check the token's own expiry. This is the cacheable part.

User lookup by index

The user is resolved by the index encoded in the token, not by username. A database user whose row was removed has its slot poisoned rather than deleted — deleting it would shift the index of every entry after it, indexes already embedded in issued tokens — and a token pointing at a revoked index is rejected outright.

Expiry policy

Checked against the current token_expiry_seconds, not the value in force when the token was issued.

Revocation set

Checked against the token ID. Synced across instances via Redis when configured. See /adm/revoke.

The practical consequence: revoking a token, editing a user, or lowering token_expiry_seconds all take effect immediately, rather than when a cache entry ages out.

config.jsonroutes.yml · vhost + routeArchitecture

Session renewal #

A session that is being used gets a fresh token at a regular interval, delivered in a new Set-Cookie. The visitor stays signed in without doing anything, the token in their browser keeps changing, and each replaced token stops working shortly after.

Renewal is off by default. Set session_refresh_interval_secs to turn it on — nothing else is required.

config.json
{
  "token_expiry_seconds": 3600,
  "session_refresh_interval_secs": 900,
  "session_refresh_grace_secs": 30,
  "session_max_lifetime_secs": 43200
}
FieldDescriptionDefault
session_refresh_interval_secs1.3.0per-vhostinteger · seconds
Age after which the next request carrying the session cookie receives a new token. The interval counts from the last renewal, not from the login. 0 disables renewal: a session ends when its token expires. Must be shorter than token_expiry_seconds to have any effect — a warning says so at startup otherwise.
0 — off
session_refresh_grace_secs1.3.0per-vhostinteger · seconds
How long a replaced token keeps being accepted. Covers requests already in flight, and other tabs that have not received the new cookie yet.
30
session_max_lifetime_secs1.3.0per-vhostinteger · seconds
Hard end of a session, counted from the original login and never reset by renewals. Past it the session is treated as expired: a page goes to the login page, an API call gets 401. A renewed token never outlives this limit. Works with or without renewal.
0 — no limit

All three can be set in config.json, on a vhosts: group, or on a single route; the narrowest scope wins. A vhost can therefore renew sessions every minute while another sets session_refresh_interval_secs: 0.

What happens on a renewal

sequenceDiagram participant B as Browser participant PA as ProxyAuth participant S as Revocation store B->>PA: request with token A (issued 16 min ago) Note over PA: A is valid and older than the interval PA->>PA: issue token B — new id, full lifetime,
same login time PA->>S: refuse A from now + grace,
forget it when A expires PA->>B: the page + Set-Cookie: session_token=B B->>PA: a request already on its way with token A PA->>B: the page + the same token B B->>PA: the next request, now with token B Note over PA: B names A as the token it replaced PA->>S: refuse A two seconds from now PA->>B: served normally B->>PA: token A, copied elsewhere, two seconds later PA->>B: 302 to the login page (401 for an API call)
A new token is issued

New token id, a full token_expiry_seconds lifetime — capped at the end of the session when session_max_lifetime_secs is set — the original login time carried over, and the id of the token it replaces.

The response carries it

A Set-Cookie with exactly the attributes of the login cookie: session_token, Path=/, no Domain, Secure, HttpOnly, SameSite=Strict, and Max-Age resolved the same way. The browser replaces the old cookie rather than adding a second one. A Set-Cookie sent by the backend on the same response is kept.

The old token is revoked with a delay

Through the regular revocation store, so every instance sharing Redis applies it. At this point it is refused once the grace period is over, and its record is purged when it would have expired anyway.

The old token is retired as soon as the new one is used

The first request carrying the new token proves the browser has it. The token it names as its predecessor is then refused two seconds later — or sooner with a shorter grace period — whatever remains of the grace period. The two seconds cover requests already sent with the old cookie while the page was loading; they still receive the same new token. The refusal is written once, and can only ever be brought forward: a late request with the old token never pushes it back. An instance that has not yet heard of the renewal records the predecessor itself, bounded by token_expiry_seconds.

Concurrent requests share one renewal

A page and its assets, or several tabs, arriving with the same old token all receive the same new token instead of minting one each. This deduplication is per process.

Where renewal applies

RequestBehaviorRenewed
required_login route
The session is validated anyway; renewal happens when it is due.
yes
public route with a session cookie
The cookie is checked. Valid and due: renewed. Invalid, expired, past its grace period or past session_max_lifetime_secs: the cookie is cleared. The page is served in every case — a public route is never blocked by a session problem.
yes
public route without a cookie
Nothing to check.
—
Authorization: Bearer
A bearer client has nowhere to receive a cookie. It refreshes by calling /auth before expires_at.
no
connection not considered secure
The session cookie is ignored there altogether — enable tls, or list the TLS-terminating proxy in trust_proxy_forward_for.
no
OIDC vhost
The relying party manages its own sessions.
no
Renewal happens on a request
There is no timer pushing cookies to idle browsers. A token is replaced on the first request that reaches ProxyAuth after the interval. A visitor inactive for longer than token_expiry_seconds signs in again: renewal keeps a session that is used alive, not an abandoned one.
Browser caching
A page served from the browser's cache never reaches ProxyAuth, so it cannot carry a new cookie. That is why a signed-in visitor's pages are sent with private, no-cache and a response carrying a session cookie with private, no-store — see Caching. The back and forward buttons may still show a page from memory; the next navigation renews.
Logout ends both
/logout revokes the token it receives and, when that token came from a renewal, refuses the token it replaced at once. The grace period is therefore only ever a window for a page that is still loading — never a way to keep an old cookie alive.
What the grace period is for now
It only matters until the new token is used, which for a browser is the next request. Its default of 30 seconds covers a slow page load or a request retried after a network hiccup. A copied cookie stays usable at most two seconds past the browser's next request.

Tokens issued before 1.3.0

A token now carries its issue time, the session's login time and, when it comes from a renewal, the id of the token it replaced, next to the user index. Tokens issued by an earlier version carry only the index: they remain valid, their age is estimated from their remaining lifetime, and they move to the new format at their first renewal.

Checking it works

  1. At startup, look for session renewal every 900s, previous token accepted 30s more. session renewal off means the setting is not being read — see Startup checks.
  2. Sign in, wait longer than the interval, then open or reload a page served by ProxyAuth.
  3. In the browser's developer tools, the response carries set-cookie: session_token=… and cache-control: private, no-store, max-age=0. The stored cookie value has changed.
routes.yml · vhost + routeArchitecture

Login redirect #

What a request gets when it reaches a required_login route without a valid session — no cookie, an expired or revoked one, or a session past its maximum lifetime — and what a visitor who is already signed in gets when they open the login page.

routes.yml
vhosts:
  - vhost: ["blog.example.com"]
    logout_redirect_url: "/login"     # the login page
    login_redirect_url: "/admin"      # where a login lands
    allow_users: ["admin"]
    routes:
      - prefix: "/"
        target: "http://127.0.0.1:9005"
      - prefix: "/login"
        static: "/var/www/blog/"
        static_index: "login.html"
        tag_proxyauth: true
        cache: false
      - prefix: "/admin"
        target: "http://127.0.0.1:9005"
        preserve_prefix: true
        required_login: true

Which page is the login page

Resolved per route, first match wins:

  1. redirect_login_url on the route;
  2. redirect_login_url on its vhosts: group;
  3. logout_redirect_url on the vhost, then in config.json — the page ProxyAuth already treats as the login page, since that is where a failed login displays its error.

With none of them set, a request without a session keeps the historical 401.

FieldDescriptionDefault
redirect_login_url1.3.0per-vhoststring
Where a browser without a valid session is sent. A path starting with /, optionally with a query string, or an absolute http(s):// URL — anything else, including //host and relative paths such as login.html, stops startup with the route named. Also accepted as login_page_url. Different from login_redirect_url, which is where a visitor lands after signing in.
logout_redirect_url

A request without a valid session

RequestWhyResponse
GET / HEAD — a page
A browser navigation, a link, a plain curl. An invalid cookie is cleared on the way.
302 to the login page, Cache-Control: no-store
Authorization header
An API client with a missing or bad bearer token needs a status it can act on, not an HTML page.
401
fetch(), XHR, images, scripts
Recognised by Sec-Fetch-Mode other than navigate, or by X-Requested-With.
401
Accept: application/json
Also XML and text/event-stream, unless text/html is accepted too.
401
POST, PUT, DELETE…
A redirect would drop the body the client just sent.
401
the login page is this page
A redirect would loop. Logged as a warning.
401

A signed-in visitor

SituationBehaviorResponse
opens the login page
There is nothing to sign in to.
303 to login_redirect_url
opens any other page
Served normally — including a public home page at /. Earlier versions forwarded every signed-in visit to /, which made a public site unreachable once signed in.
the page
not allowed on this route
Signing in again would not change the answer, and a login page that forwards signed-in visitors would bounce straight back. The session is kept.
403
The historical layout
When no login page is configured at all, the site root counts as the login page: a signed-in visitor opening / is still forwarded to login_redirect_url, exactly as before.
Back on the login page after signing in?
The session cookie is being ignored because the connection is not considered secure — tls is off and no trusted proxy says the request arrived over HTTPS. ProxyAuth logs a session cookie was sent but is ignored because the connection is not considered secure. Enable tls, or list the TLS-terminating proxy in trust_proxy_forward_for.
config.jsonroutes.yml · vhost + routeHTTP API

Refresh tokens #

For API sessions — vhosts with session_cookie: false — /auth can return a refresh token next to the access token. The client keeps a short-lived access token and exchanges the refresh token for a new pair at POST /refresh_token, without sending the password again.

The access token and the refresh token are two different tokens that carry the same token id, for the whole life of the session. Revoking that id ends the session: both tokens, and every pair issued by later refreshes.

config.json
{
  "session_cookie": false,
  "csrf_token": false,
  "token_expiry_seconds": 600,
  "refresh_token_expiry_seconds": 86400
}
FieldDescriptionDefault
refresh_token_expiry_seconds1.3.0per-vhostinteger · seconds
Lifetime of the refresh token. 0 turns the feature off: /auth returns no refresh_token and /refresh_token answers 400. Only applies where session cookies are off — a vhost with session_cookie: true has no refresh tokens. Settable on a vhosts: group or a route, so one API can use them while another does not.
0 — off

session_max_lifetime_secs applies here too: neither token of a pair outlives the end of the session, and a refresh past it is refused.

POST /auth

shell
$ curl -s -X POST https://api.example.com/auth -d 'username=ci-runner&password=...'
response · 200 · Cache-Control: no-store
{
  "token": "AS1mFWRmJ3s_…",
  "expires_at": "2026-09-17 10:29:05 +02:00",
  "refresh_token": "AXtkX_71LsWz…",
  "refresh_token_expires_at": "2026-09-18 10:19:05 +02:00"
}

With refresh_token_expiry_seconds: 0 the response keeps its earlier shape, token and expires_at only.

POST /refresh_token

POST/refresh_token
Body{"refresh_token": "..."} as JSON, or refresh_token=... as a form
Returns200 with a new pair, in the same shape as /auth, sent with Cache-Control: no-store
ChecksA refresh token — an access token is refused — valid, not expired, not revoked, not longer-lived than the configured lifetime, of the session's current generation; the account still exists, its address is still allowed, and the vhost's login authorization still names it; the session is within session_max_lifetime_secs.
LimitsRate-limited with /auth by ratelimit_auth. On an OIDC vhost the path is proxied to the backend, like /auth.
shell
$ curl -s -X POST https://api.example.com/refresh_token \
    -H 'content-type: application/json' \
    -d "{\"refresh_token\": \"$REFRESH\"}"
StatuserrorWhen
400unsupported_grant_typeRefresh tokens are off for this host: lifetime 0, or session cookies in use.
400invalid_requestNo refresh_token, or a body that is neither JSON nor a form.
401invalid_grantInvalid, expired or revoked token; an access token; a session past its maximum lifetime; a refresh token already used — which also revokes the session.
403access_deniedThe account is no longer allowed on this vhost or from this address.

Errors are JSON — {"error": "...", "error_description": "..."} — with the codes OAuth 2.0 clients already know.

One token id, several generations

sequenceDiagram participant C as API client participant PA as ProxyAuth participant S as Revocation store C->>PA: POST /auth PA->>C: access A0 + refresh R0 — token id X, generation 0 C->>PA: GET /data — Bearer A0 PA->>C: 200 C->>PA: POST /refresh_token — R0 PA->>S: session X is now at generation 1 PA->>C: access A1 + refresh R1 — same token id X C->>PA: GET /data — Bearer A0 PA->>C: 401 — superseded C->>PA: GET /data — Bearer A1 PA->>C: 200 Note over C,PA: a copy of R0 is replayed C->>PA: POST /refresh_token — R0 PA->>S: revoke token id X PA->>C: 401 invalid_grant C->>PA: GET /data — Bearer A1 PA->>C: 401 — the session is revoked
MechanismBehaviorWhy
same token id
Both tokens of every pair of a session carry the id issued at login.
/adm/revoke with that id ends the whole session
token kind
A refresh token is marked as such. It is refused on every route; an access token is refused by /refresh_token. Versions before 1.3.0 cannot read the mark and refuse refresh tokens too.
a long-lived token never opens a route
generation
Each refresh moves the session to the next generation, recorded under refresh:<token id> in the revocation store and shared through Redis. Tokens of an older generation — the previous access token included — are refused at once. The check and the increment happen in one step, so two simultaneous refreshes with the same token cannot both succeed on one instance.
rotation: each refresh token works once
reuse detection
A refresh token of an older generation means two parties hold it. The session's token id is revoked, and both the legitimate client and the copy have to sign in again.
a stolen refresh token is caught on first use
login time
Carried over from pair to pair, so session_max_lifetime_secs counts from the original sign-in.
a hard end to every session
Refresh once, then use the new pair
A refresh invalidates the previous access token immediately, and a second refresh with the same refresh token revokes the session. A client should serialise its refreshes, replace both tokens as soon as the response arrives, and retry a 401 request once with the new access token rather than refreshing again. A network retry of a refresh that actually succeeded looks exactly like a replay.
Across instances
The generation reaches other instances on their next sync tick, up to 15 seconds later. During that window, another instance may still accept the previous access token, and a refresh on two instances at once cannot be detected as a reuse. Point refresh calls at one instance, or accept that window.
How long revocations are kept
A refresh token can live longer than an access token. Revocation records written by /adm/revoke and by reuse detection are therefore kept for the longest lifetime of any token the instance issues — token_expiry_seconds or the largest refresh_token_expiry_seconds, globally or on any vhost.
config.json
The server itself: what it listens on, what signs its tokens, how it talks to a database, and where it logs. Everything below lives in this one file, at the top level.
Scopeglobal — the whole instance
Reloadrestart required
Default path/etc/proxyauth/config/config.json
config.jsonServer & networking

Server & networking #

config.json is read once at startup. With the sole exception of each user's otpkey (see TOTP enrollment), changes require a service restart — unless the equivalent setting is one of the per-vhost overrides, which apply live as soon as routes.yml is reloaded.

FieldDescriptionDefault
secretrequiredstring
Cryptographic signing secret that tokens are derived from — a random value of 64 characters or more. Combined at startup with a 32-byte per-build salt via HKDF-SHA256 into the actual encryption key, so secret alone is not enough to forge a token without also having the matching build. See Tokens.
—
token_expiry_secondsrequiredinteger
Seconds before an issued token expires.
1 – 31 536 000
(5 years max)
usersarray
File-based accounts. See The User object. Merged at request time with any databases-backed accounts.
[]
token_adminstring
Admin token, required in the X-Auth-Token header to call every /adm/* endpoint. Treat it like a root credential — those endpoints are not rate-limited.
—
hostarray or string
Address or addresses ProxyAuth listens on. A bare string is accepted for backward compatibility; an array lets one instance bind several addresses at once, IPv4 and IPv6 together for instance.
0.0.0.0
portinteger
Listening port.
8080
1 – 65535
tlsboolean
Enable HTTPS/TLS. Required on any vhost acting as an OIDC provider — an issuer must be https. When this is on and at least one route sets certbot_renew, a second plain-HTTP listener is bound on port 80 automatically; see Let's Encrypt.
true
workerinteger
Worker threads per instance. Should roughly match available CPU cores.
4
num_instancesinteger
Number of independent actix servers started inside the same process, each with its own accept loop on the shared port via SO_REUSEPORT. Total concurrency is num_instances × worker. They share one address space, so anything held in memory — the rate-limit counters, the token verification cache — is shared between them.

Raising this is rarely the first thing to reach for: worker already gives thread-level parallelism within one server. Separate accept queues help under very high connection churn, and little else.
2
max_connectionsinteger
Maximum simultaneous connections accepted.
50000
pending_connections_limitinteger
Backlog of connections waiting to be accepted.
65535
client_timeoutinteger · ms
How long ProxyAuth waits for a client to finish sending its request.
5000
keep_aliveinteger · ms
Keep-alive duration for idle client connections.
5000
max_idle_per_hostinteger
Maximum idle pooled connections kept open per backend host, reused across requests instead of renegotiating each time.
50
0 – 3000
max_body_sizeinteger · bytes
Largest request body accepted before the request is rejected outright.
10485760
10 MB
backend_timeout1.2.5integer · ms
How long a proxied request may take before it is abandoned. What it actually bounds depends on streaming — see Backend timeout.
10000
socket_listeninteger
Listen backlog passed to the socket itself, distinct from pending_connections_limit above.
1024
fastboolean
Trades request-time flexibility for raw throughput on the hot proxy path, and skips the extra cost function in token verification.
false
strict_mtlsboolean
How a route's client certificate (mTLS) is handled when it cannot be read or fails to pair. false connects without client authentication and logs a warning; true answers 502 instead. See Client certificates for which fits which deployment.
false
cache_duration_secsinteger · seconds
Default max-age for every route with cache: true and no override of its own. 0 disables caching at the HTTP layer even when cache is on — the header becomes max-age=0.
300
compressionobject
Global compression defaults, overridden per vhost group or per route. See Compression.
—
loggingobject
Access-log line format and the global, per-vhost and per-route on/off switches. Deliberately separate from log, which configures the transport. See Logging.
—
letsencryptobject
ACME renewal settings shared by every certbot_renew vhost. Also accepted under its original name acme. See Let's Encrypt.
—
config.jsonServer & networking

Backend timeout #

How long a proxied request may take before ProxyAuth gives up on it, in milliseconds. Raise it for a backend that is legitimately slow to answer; lower it to fail faster when one stops responding.

config.json
{
  "backend_timeout": 30000
}
AspectDetailValue
scope
Both proxy paths and a redirect_protect.target fetch. The short internal probe behind paths keeps its own much tighter budget — a session check that runs ahead of every matching request has no business inheriting the budget of a large transfer.
proxied requests
0
Restored to the default rather than taken literally. A zero-length timeout would abandon every proxied request the instant it is issued — never what an operator means, even though 0 legitimately means "off" for the rate limits and the refresh intervals.
10000

What it covers depends on streaming

With streaming off, the response is collected in full before anything is sent on, so this bounds the whole exchange. With streaming on it bounds only the arrival of the response headers — the body then flows for as long as it takes, which is the entire point of streaming a large transfer.

The gap this leaves
On a streamed route, a backend that sends its headers and then stalls holds the connection open indefinitely — nothing bounds the body transfer. An idle timeout between frames would close that gap and does not exist yet. Worth knowing before enabling streaming on a route pointing at something you do not operate.
config.jsonAuthentication & sessions

Authentication & sessions #

Every field in this section can also be set per vhost, overriding the global default for that vhost alone — see Per-vhost overrides. What follows is the fallback a vhost uses when it sets nothing of its own.

FieldDescriptionDefault
login_via_otpper-vhostboolean
Require a TOTP code at login, in addition to username and password.
false
session_cookieper-vhostboolean
Issue and check a session_token cookie after authentication, instead of — or alongside — bearer-token auth. Flags set: Secure, HttpOnly, SameSite=Strict.
false
max_age_session_cookieper-vhostinteger · seconds
Session cookie lifetime. The cookie never outlives the token it carries, so the effective value is capped at token_expiry_seconds. Also accepted as session_cookie_age.
3600
60 – 31 536 000
session_refresh_interval_secs1.3.0per-vhostinteger · seconds
Renew a session token once it is this old, in a new Set-Cookie, without signing in again. 0 disables renewal. See Session renewal.
0 — off
session_refresh_grace_secs1.3.0per-vhostinteger · seconds
How long a token replaced by a renewal keeps being accepted.
30
session_max_lifetime_secs1.3.0per-vhostinteger · seconds
Hard end of a session, counted from the original login and not reset by renewals or refreshes. 0 means no limit.
0
refresh_token_expiry_seconds1.3.0per-vhostinteger · seconds
Lifetime of the refresh token /auth returns in JSON mode, next to the access token and with the same token id. 0 disables refresh tokens. See Refresh tokens.
0 — off
login_redirect_urlper-vhoststring
Where a fresh successful login lands, and where a visitor who is already signed in is sent when they open the login page. Other pages are served normally to a signed-in visitor — see Login redirect.
"/"
logout_redirect_urlper-vhoststring
Where /logout sends the visitor afterward. It is also the login page: the page annotated with an error message on a login failure — see CSRF § Error pages — and, unless redirect_login_url says otherwise, where a browser without a valid session is sent from a required_login route.
—
csrf_tokenper-vhostboolean
Master switch for CSRF protection. Overridden per vhost under the name tag_csrf_token. See CSRF.
true
cors_originsper-vhostarray 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.
  • A same-origin request is never blocked by this list, checked or not — including a POST, PUT or DELETE that a browser attaches an Origin header to even when it matches the page already loaded. The comparison is against the actual scheme and host the request arrived on, since this allow-list only ever meant to restrict genuinely external callers.
  • Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true are never sent on the same response — browsers reject that combination outright, so the wildcard that OIDC discovery and JWKS rely on stays valid regardless of credentials headers configured elsewhere.
null
statsboolean
Enables /adm/stats and /adm/stats/sessions (still gated by token_admin) and the local proxyauth stats socket. Tracks per-token usage counters in memory.
false
timezonestring
IANA timezone used for token timestamps.
Europe/Paris

Password reset & SMTP

FieldDescriptionDefault
smtpper-vhostobject
SMTP server used to send password-reset links via proxyauth reset-password. Optional — omit entirely if unused. Fields: host, port, username, password, from, timeout_secs.
—
page_change_passwordper-vhoststring
The URL a browser is sent to, with ?token=… appended, to set a new password — after a reset email, or automatically on first login for an account carrying must_change_password.
—
config.jsonRate limiting

Rate limiting #

ratelimit_auth and ratelimit_proxy share the same three keys, applied independently to /auth traffic and to proxied traffic. A burst of bad login attempts cannot drown out real API traffic, or the reverse.

FieldDescriptionDefault
requests_per_secondinteger
Sustained requests per second allowed. 0 disables rate limiting for that traffic class entirely.
0 — disabled
burstinteger
Extra requests allowed above the sustained rate before blocking kicks in.
1
block_delayinteger · ms
How long a client that exceeded its limit is held before its next request is even considered.
500
config.json
"ratelimit_auth": {
  "requests_per_second": 5,
  "burst": 10,
  "block_delay": 1000
},
"ratelimit_proxy": {
  "requests_per_second": 200,
  "burst": 400,
  "block_delay": 200
}

Requests within the limit pass straight through; anything over the burst receives a clean 429, not a dropped connection.

One counter, whatever num_instances is

The counters are built once at startup and shared by every num_instances server and every worker thread under them. The burst you configure is therefore the burst enforced across the whole host.

Turning one off

requests_per_second: 0 is what disables a limiter, and it disables it completely: no counter is built and no middleware is wrapped around the traffic class.

ratelimit_authratelimit_proxyWhat is limited
0
1 or more
proxied traffic only
1 or more
0
/auth only
1 or more
1 or more
both
0
0
nothing
burst: 0 is a different case
A burst of zero is not representable by the underlying limiter. When requests_per_second is non-zero — so a limit was clearly intended — the value is clamped to 1 and a warning names which of the two limits it came from. Treating it as "off" would silently drop protection an operator asked for; requests_per_second: 0 is how you disable one.
Scope
Rate limiting covers /auth and proxied traffic. /adm/* is outside both classes so an operator or a monitoring system is never throttled during an incident; those endpoints are gated by token_admin instead.
config.jsonScaling & shared storage

Scaling & shared storage #

What a single instance needs in order to behave consistently as one of several.

FieldDescriptionDefault
redisstring
Redis URL for multi-node token revocation sync. Redis is the sync bus; LMDB remains the source of truth on each node. Without it, revoking a token on one instance does not affect any other.
—
databasesobject
PostgreSQL or MySQL connection for shared, database-backed users across every instance. Connection fields are given individually, not as a URL: db_type ("postgres" or "mysql"), host, port, db_name, user, password. See Database-backed users for the timers and the sync model.
—
blakegateexperimentalarray
External BlakeGate WebSocket endpoints that ProxyAuth pushes live in-memory config changes to, near-instantly. Intended for very large deployments — a million accounts and beyond.
[]

Sharing accounts is not the same as sharing token validity. Two instances with the same databases block still reject each other's tokens until their build constants agree — see SSO across instances.

config.jsonScaling & shared storage

Revocation & Redis sync #

Three storage layers hold the same revocation set, each answering a different need: an in-memory map read on every request, an LMDB store that survives a restart, and — when redis is configured — a bus that carries a revocation to every other instance.

A revoked token is refused as soon as its id is in the store. Every entry also says when it can be forgotten: a token never lives longer than token_expiry_seconds, so no revocation needs to be kept beyond that, and nothing is kept forever.

The three layers

LayerRoleRead on the request path
DashMapin memory
The only layer token verification touches. A concurrent map keyed by token id, holding the date the entry can be purged.
yes — every request
LMDB/opt/proxyauth/db/
The revoke database, written on every revocation. Read in full at startup to rebuild the map, and on every sync tick. This is what makes a revocation survive a restart, and what lets another process on the same host add one.
no
Redisoptional
The sync bus between instances, configured through redis. Without it everything above still works, and a revocation is simply local to the node.
no

How long an entry is kept

Written byKept untilWhy that date is safe
now + the longest token lifetime: token_expiry_seconds, or the largest refresh_token_expiry_seconds when refresh tokens are on. An exp in the request is accepted and ignored.
only the token id is known, and every token existing now has expired by then
The expiry of the token being logged out.
the token is at hand, so its real expiry is known
Refused from now + session_refresh_grace_secs — brought forward to two seconds after the new token is first used, and to the moment of a /logout — and kept until the replaced token's own expiry. Stored under rotated:<token id>, with both dates in one value.
same as logout

What LMDB records

Each entry is the token id and a 16-byte value: when the revocation was made, then when it can be purged, both as big-endian Unix seconds.

ValueMeaningWritten by
16 bytes
revoked_at + purge date. For a rotated: entry, the second half packs the refusal start and the purge date.
1.3.0
8 bytes
The purge date alone. Read as is.
earlier versions
empty
"Kept forever". On first read it is given now + token_expiry_seconds and rewritten — safe, since every token it could refer to was issued earlier. The startup log says how many were converted.
earlier versions

Writing a revocation

The in-memory map is updated first, so the token stops working on this instance before anything else is attempted. The entry is then written to LMDB and, when redis is configured, published on the bus as three keys:

KeyContentExpiry
token:<id>
The value stored in memory.
until the purge date, at least 60 s
<id>_action
1 — what the sync loop scans for.
same
<id>_count
Incremented on each revocation of that id.
same

The three keys are written in one atomic pipeline and share one TTL, so Redis cleans itself up. The 60-second floor guarantees every instance sees an entry on at least one tick, even one about to be purged.

The sync loop

One background task per instance, with or without Redis, ticking every 15 seconds. LMDB and Redis calls run off the request threads.

flowchart TD A(["Tick — every 15s"]) --> B{"Redis
configured?"} B -->|no| E B -->|yes| C["SCAN for *_action keys"] C --> D{"action value"} D -->|"1 — value present"| D1["Apply to memory,
write to LMDB if it changed"] D -->|"1 — value gone or past its date"| D4["Delete the stale keys
from Redis"] D -->|"2 — remove"| D2["Delete from LMDB
and from memory"] D -->|anything else| D3["Skipped"] D1 --> E["Read LMDB back into memory,
giving old forever entries an end date"] D2 --> E D3 --> E D4 --> E E --> F["Purge entries past their date
from memory, LMDB and Redis"] F --> G(["Done until the next tick"]) 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 class A start class B,D decision class C,D1,D2,D3,D4,E,F step class G success
StepDetailNote
1 · drain Redis
A SCAN over *_action keys. 1 applies the entry, 2 removes it, any other value is skipped rather than guessed at. An entry whose value has disappeared, or whose purge date has passed, is removed from Redis instead of being applied — earlier versions read a missing value as a permanent revocation. A legacy "forever" value is bounded and republished with a TTL.
skipped without Redis
2 · LMDB → memory
A cursor over the whole revoke database, reinserting every entry into the map. This is what lets a revocation written by another process on the same host be picked up without a restart.
every tick
3 · purge
Entries past their purge date are dropped from memory, deleted from LMDB in one transaction, and their three keys deleted from Redis. A token whose entry is purged has already expired on its own.
every tick
4 · shutdown
The loop watches SIGTERM and SIGINT, and flushes LMDB to disk before exiting.
on signal

Propagation delay

The instance handling the revocation enforces it immediately. Every other instance picks it up on its next tick, so worst case is one tick — up to 15 seconds across a fleet.

A window, not an instant
A token revoked on node A stays usable on node B for up to 15 seconds. That is the right order of magnitude for a logout or an admin action, and the wrong one if you are relying on revocation to contain an active incident — in that case restart the fleet, or take the compromised node out of rotation, rather than waiting on the bus.
Redis is not a dependency
Initialization retries three times, five seconds apart, and the server keeps starting either way. A connection that fails on a given tick logs the error and the Redis part of that tick is skipped. Local enforcement never stops, because it reads the in-memory map, which is rebuilt from LMDB regardless.
Give ProxyAuth its own Redis database
The database is shared by every instance pointed at it, and the scan pattern is broad. Use a database number no other application writes to, and a separate one for each group of instances that should not share revocations.
Testing against Redis
The storage tests run against a real LMDB, and against Redis when one is given: PROXYAUTH_TEST_REDIS=redis://127.0.0.1:6379/15 cargo test --test revoke_sync. Without the variable, the Redis cases are skipped.
config.jsonLet's Encrypt

Let's Encrypt & ACME #

Global settings shared by every vhost that opted into automatic renewal with certbot_renew: true. One check interval, one renewal threshold, one ACME account for the whole instance. The key is letsencrypt; acme is still accepted as an alias, matching the feature's original name.

config.json
"letsencrypt": {
  "check_interval_secs": 3600,
  "renew_before_days": 30,
  "directory_url": "https://acme-v02.api.letsencrypt.org/directory",
  "contact_email": "ops@example.com",
  "account_credentials_path": "/etc/proxyauth/acme/account.json"
}
FieldDescriptionDefault
check_interval_secsinteger
How often every certbot_renew vhost's certificate is checked to decide whether it needs renewing. A check interval, not a renewal interval — most checks find nothing due and do nothing.
3600 — hourly
renew_before_daysinteger
Renew when the certificate has this many days or fewer left. Let's Encrypt certificates are valid for 90 days; 30 is the usual convention, leaving room for several renewal attempts within the remaining validity.
30
directory_urlstring
The ACME directory to use. Point it at staging while testing — production has strict rate limits, a handful of certificates per registered domain per week, and staging exercises the exact same code path.
Let's Encrypt production
contact_emailstring
Contact address given to Let's Encrypt for the ACME account — expiry reminders, and how they would reach you about an account issue. Optional, but recommended.
—
account_credentials_pathstring
Where the ACME account's private key is persisted between restarts — the account, not the TLS certificate. Registering once and reusing the account is what keeps an instance well within Let's Encrypt's account-level rate limits however often it is restarted.
/etc/proxyauth/acme/account.json

One certificate per file, not per name

A vhost group listing several names shares one vhost_cert, and that file is what defines one certificate. Every name pointing at the same fullchain.pem is issued together, on a single certificate carrying all of them as SANs.

routes.yml — one certificate, two names
- vhost: ["example.com", "www.example.com"]
  certbot_renew: true
  vhost_cert:
    cert: "/etc/proxyauth/cert/example.com/fullchain.pem"
    key: "/etc/proxyauth/cert/example.com/privkey.pem"

This applies to certbot new, certbot renew <vhost> and certbot renew all alike. Naming a single member of a group renews the whole group and says so — reissuing one name alone would overwrite the shared file with a certificate no longer covering its siblings.

certbot check lists the SANs actually present, which is the quickest way to confirm what a file holds.

Wildcard certificates

A group containing a wildcard vhost can be issued a certificate, but not the same way as everything above.

Why this is manual
Let's Encrypt issues a wildcard certificate only through DNS-01 validation. HTTP-01 cannot prove control of *.example.com — there is no hostname to serve a response on. DNS-01 means publishing a TXT record, and ProxyAuth does not integrate with any DNS provider's API, so this step is done by hand.

DNS-01 is selected automatically as soon as any name in the group starts with *.. The command prints the records to create, waits for you to confirm, then asks Let's Encrypt to validate.

shell
$ sudo proxyauth certbot new example.com
$ dig +short TXT _acme-challenge.example.com
Excluded from automatic renewal
A group containing a wildcard is skipped by the periodic scan, with a warning naming it — a prompt nobody is watching is not a renewal. These certificates must be renewed by hand before they expire. Set a reminder; certbot check reports the days remaining.
Two values at one record name
A group covering both an apex and its wildcard validates both under _acme-challenge.example.com, producing two different TXT values at the same record name that must exist simultaneously. Many DNS interfaces treat a TXT record as single-valued and replace the first with the second; validation then fails for whichever name lost, and the whole order fails with it. Add them as two separate records.
Rehearse on staging
Point directory_url at https://acme-staging-v02.api.letsencrypt.org/directory for the first attempt. Staging's rate limits are far wider, which matters because a first manual DNS-01 often needs a second try.

The port 80 listener

Let's Encrypt always fetches http://{vhost}/.well-known/acme-challenge/{token} over plain HTTP on port 80, and that is not configurable on their end. A TLS-only deployment — everything real on 443, nothing listening on 80 — could therefore never complete a renewal without something answering there. ProxyAuth handles this itself rather than making you run a second web server for it.

AspectBehaviorNote
when
Spawned only when tls is true and at least one route in the whole file sets certbot_renew. Neither condition alone is enough. A plain-HTTP main server already answers challenges itself, so nothing extra is spawned in that case.
startup
where
One listener per address in host, each on port 80. Binding a port below 1024 needs root, so this happens during the privileged phase of startup, before privileges are dropped to run_user.
every bind address
what it serves
A challenge token that exists returns the key authorization. Everything else gets a 301 to the same URL on https:// — which is what operators generally expect from port 80 on an HTTPS-only site anyway.
nothing else
if the port is taken
The address is logged and skipped, and startup continues normally. An instance sharing a host with another web server keeps serving all its own traffic; only the challenge listener for that address is unavailable.
non-blocking
Confirm at startup
Two log lines report the outcome for each address:

ACME: HTTP-01 challenge listener bound on 0.0.0.0:80 — ready to answer challenges.
ACME: failed to bind the HTTP-01 challenge listener on … — free the port, or point host at an address where port 80 is available.
Detail
The challenge itself is exchanged through an LMDB store at /opt/proxyauth/db/acme_challenges, shared between the short-lived proxyauth certbot process and the running server. An unknown or expired token returns a plain 404, answered by the challenge listener itself. Validation results therefore stay independent of the main server's routing rules on 443 — an allow_ips restriction or a maintenance gate on a vhost has no bearing on what Let's Encrypt sees.
Renewal is atomic
A failed attempt writes nothing: the certificate already in place keeps being served, and the next scheduled check tries again. On success, the per-vhost file watcher set up at startup picks the new files up on its own — no reload step, no restart, no downtime.

That watcher is created at startup from the vhost_cert paths present then, so a vhost added to a running server needs one restart to join the watch. Existing vhosts renew live.
config.jsonOperational settings

Operational settings #

FieldDescriptionDefault
run_user / run_groupstring
The unprivileged user and group ProxyAuth drops to after binding its listeners. Everything requiring root — a low port, a certificate directory — is set up first, then privileges are dropped for the actual request-handling lifetime.
proxyauth
logobject
Logging transport, for every line — access and diagnostic alike. {"type":"local"} for local files, {"type":"loki","host":"…"} to stream to Grafana Loki, {"type":"http","max_writes_log":10000} for a generic HTTP sink, {"type":"disabled"} to silence everything. Distinct from logging, which only governs the per-request access line — see Logging.
{"type":"local"}
ip_blocklistsarray
External IP/CIDR abuse blocklists — Spamhaus DROP, FireHOL, AbuseIPDB exports — checked against every request's resolved client IP before any route matching or auth work. Full reference, including the source object and the local cache: IP abuse blocklists.
[] — feature off
ip_blocklist_refresh_interval_secsnumber
How often ip_blocklists sources are re-fetched. 0 fetches once at startup and never refreshes again. Ignored when the list is empty.
3600
redirect_protect_refresh_interval_secsnumber
How often every route's redirect_protect.allow_url_ips and deny_url_ips sources are re-fetched — see Maintenance mode. Ignored when no route configures either field.
3600
trust_proxy_forward_forarray
Trusted upstream proxy IPs allowed to set X-Forwarded-For. Without an entry for your actual load balancer or CDN, ProxyAuth ignores that header and uses the real TCP peer address for rate limiting and IP allow checks instead.
[]
config.jsonOperational settings

IP abuse blocklists #

Published abuse feeds — Spamhaus DROP, FireHOL, an AbuseIPDB export, your own file — fetched on a timer and checked against every request before anything else happens.

This is the first gate in the request lifecycle. A blocked address never reaches route matching, authentication or CSRF, so nothing downstream can be used to get around it.

config.json
{
  "ip_blocklists": [
    { "source": "https://www.spamhaus.org/drop/drop.txt", "name": "spamhaus" },
    { "source": "https://iplists.firehol.org/files/firehol_level1.netset", "name": "firehol1" },
    {
      "source": "https://internal.example.com/exports/abuse.csv.gz",
      "name": "internal",
      "csv": true,
      "csv_column": 2
    },
    { "source": "/etc/proxyauth/local-bans.txt", "name": "local" }
  ],
  "ip_blocklist_refresh_interval_secs": 3600
}

The source object

FieldDescriptionDefault
sourcerequiredstring
An http(s):// URL or a local file path. One address or CIDR per line, or CSV.
—
namestring
Names the local cache file. Presentation only — without it a name is derived from source, with anything not filesystem-safe replaced by _. Worth setting: it is what makes the cache directory readable at a glance.
derived
csvboolean
Splits each line on commas and takes one field, instead of the first whitespace-separated token.
false
csv_columninteger
Which column holds the address, counting from zero. Surrounding double quotes are stripped.
0

What it accepts

These are third-party files written to no common standard, so the parser is deliberately forgiving about everything except the address itself.

InputHandlingNote
gzip
Detected from the file's own magic bytes, not from a .gz extension — so a compressed feed served from a URL that does not say so still decompresses.
automatic
comments
A line starting with # or ; is skipped, and a trailing # comment after an address is cut off. 1.2.3.4 # some ISP reads as 1.2.3.4.
skipped
trailing text
In plain-text mode only the first token on the line is read, so 1.2.3.4 some-hostname works too.
ignored
addresses
A bare IP or a CIDR, IPv4 and IPv6 alike. Anything that parses as neither is counted and reported once per refresh rather than line by line.
counted

Refresh, cache and failure

Sources are fetched on their own background task at ip_blocklist_refresh_interval_secs, never on the request path. 0 fetches once at startup and holds that result. With no source configured, no task is started at all.

BehaviorDetailResult
local cache
Every successful fetch is written to /etc/proxyauth/abuse/<name>.txt, one normalized entry per line, through a temp file and a rename — so reading a file mid-refresh never shows a half-written list.
per source
a failed fetch
An unreachable URL, a 404, a garbled payload: logged, then the last successful copy from the cache is used. A third-party outage never blanks the blocklist, and never blocks the others.
last good result
ordering
Sources are fetched one after another and merged into a single deduplicated list, hot-swapped in when complete. These feeds are large and this is a background task, so there is no latency to save by parallelising it.
sequential
startup
A broken source is skipped with a warning. It can neither panic nor prevent the server from starting — these are files nobody running ProxyAuth controls.
degrades
Reading the cache is the diagnostic
cat /etc/proxyauth/abuse/spamhaus.txt shows exactly what that source currently contributes, normalized. If an address is being blocked and you want to know which feed is responsible, grep that directory — it is faster than any log.
Which address is checked
The resolved client IP, which behind a load balancer or a CDN means X-Forwarded-For — but only from a peer listed in trust_proxy_forward_for. Without that entry, ProxyAuth checks the TCP peer address, which is your own load balancer, and the blocklist silently protects nothing. This is the single most common way to have this feature configured and inert.
Not the same as the other IP lists
Three mechanisms take IP lists and they answer different questions. ip_blocklists is global and runs before routing — a blocked address gets a flat 403 and nothing else. allow_ips/deny_ips gate one route, after it has been matched. redirect_protect.allow_ip is a maintenance gate that serves alternative content rather than refusing. The allow_url_ips/deny_url_ips of that last one reuse this exact same source object, on their own refresh interval.
config.jsonThe User object

The User object #

One entry in the users array. The database schema mirrors this shape exactly, so file-based and database-backed accounts behave identically from the application's point of view.

FieldDescriptionDefault
usernamerequiredstring
Login name.
—
passwordrequiredstring
Argon2id hash of the password. A plaintext value written here is hashed in place on first startup.
—
allowarray
IP/CIDR allow-list for this specific account. A login attempt from outside it is rejected regardless of whether the password is correct.
[] — any address
rolesarray
Forwarded to the backend as X-User-Roles, and usable for route-level access control. See Groups & roles.
[]
groupsarray
An alternative to roles specifically for route access control, with no header side effect. A route lists allowed groups, and a user gets in by belonging to any one of them.
[]
emailarray
Addresses used for password-reset links. Each entry carries the address plus a primary flag — one of them is the one a reset link is actually sent to. The CLI sets this with repeated --email flags and an optional --primary-email; it is authoritative, not a merge, so omitting it clears whatever was on file.
[]
otpkeystring
TOTP secret, base32. Not normally set by hand — populated by /adm/auth/totp/get on first enrollment. See TOTP enrollment.
—
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 a new password is set.
false
Treat this file as a credential
config.json holds key material directly: secret, token_admin, databases.password and each user's otpkey. User passwords are the exception — written in plaintext, they are replaced in place by their Argon2id hash on first startup.

Restrict the file to run_user, and back it up the way you back up private keys. proxyauth prepare sets these permissions for you on a fresh install.
routes.yml
Everything from here to the end of the Backends chapter lives in this file — routing, virtual hosts, the OIDC provider, and every middleware. Two nesting levels matter, and each section below states which one it belongs to: vhost keys sit on a vhosts: entry, route keys sit on an individual entry under routes:.
Scopevhost and route
Reloadlive — no restart
Default path/etc/proxyauth/config/routes.yml
routes.yml — the two levels
vhosts:
  - vhost: ["app.example.com"]   # ← vhost level: TLS, sessions, CSRF,
    session_cookie: true         #   SMTP, login policy, OIDC, maintenance
    routes:
      - prefix: "/api"           # ← route level: access control, caching,
        target: "http://..."     #   compression, headers, filters
        groups: ["ops"]
routes.yml · routeCore fields

Core route fields #

Every route needs exactly one content source — a proxied backend or a static file on disk — plus a prefix to match requests against.

FieldDescriptionDefault
prefixrequiredstring
The URL prefix this route matches. See Routing 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 served directly from disk, with no backend involved. A directory also needs static_index.
—
static_indexstring
The file served for the directory root when static points at a directory — 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. Without one, a page is sent to the login page and an API call gets 401 — see Login redirect.
false
redirect_login_url1.3.0string
The login page a browser without a valid session is sent to with a 302. Usually set once on the vhosts: group; falls back to logout_redirect_url. Also accepted as login_page_url. See Login redirect.
logout_redirect_url
regexstring
Match this route by regular expression instead of by prefix. Regex routes are tried first, in file order — see Routing.
—
static_rewritestring
Rewrites the path before resolving it against static.
—
preserve_prefixboolean
Keep the matched prefix in the path forwarded to the backend instead of stripping it.
false
allow_methodsarray
HTTP methods this route accepts. Unset accepts every method.
—
allow_ips / deny_ipsarray
IP and CIDR allow / deny lists for this route specifically, IPv6 included. Distinct from redirect_protect.allow_ip, which is a maintenance gate serving alternative content, and from the global ip_blocklists, which run before routing. Parsed and validated once at startup, so the request path only does a containment check. An entry that is not a valid address or CIDR stops startup with the route, field and value named — a restriction is either in force or reported.
[]
proxy / proxy_configboolean / string
Forward through an intermediate HTTP proxy. See Egress proxy.
false
Renamed
secure is now required_login. Startup validation checks for the old name and reports every route still using it, by prefix, so a configuration written against an earlier version is migrated with the list in hand rather than by searching for it.
routes.yml — proxied route
routes:
  - prefix: "/api"
    target: "http://127.0.0.1:8000"
routes.yml — static route
routes:
  - prefix: "/"
    static: "/var/www/app/public/"
    static_index: "login.html"

routes.yml · vhostVirtual hosts

Virtual hosts & per-vhost TLS #

A route with no vhost is a catch-all: it matches any Host header, the behavior every routes.yml had before vhost existed. A route with a vhost list only matches requests for one of those hostnames.

FieldDescriptionDefault
vhostarray
Hostnames this route answers to. Case-insensitive and port-stripped before comparison — Example.com:8443 and example.com match the same route. An entry may also be a wildcard pattern such as *.example.com.
[] — catch-all
vhost_certobject
A TLS certificate and key this vhost presents instead of the server's global one, selected via SNI at handshake time. Two keys, cert and key, both required if either is set. Hot-reloaded on renewal.
—
certbot_renewboolean
Enables automatic Let's Encrypt renewal for this vhost's certificate.

Setting this on a single route also opens port 80 for the whole instance. When tls is on and any route in routes.yml has this flag, ProxyAuth binds a separate plain-HTTP listener on port 80 at startup, on every address in host. It answers ACME challenges and redirects everything else to HTTPS, with nothing to configure. See Let's Encrypt & ACME for what happens on a host where port 80 is already in use.
false
routes.yml — 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 purely an authoring convenience. It is flattened into plain routes right after parsing, so nothing downstream — routing, TLS SNI resolution, the CLI audit tools — knows the form exists.

Each route inherits the group's vhost, vhost_cert, certbot_renew and every per-vhost override, unless it sets its own. Mixing both styles in one file is fine.

Detail
Certificate hot-reload watches the containing directory, not the certificate file. This is deliberate: a file-level watch dies the moment a renewal replaces the file's underlying inode, which is exactly what both Certbot's symlink swap and ProxyAuth's own atomic-rename renewal do. A directory's inode does not change when files inside it do, so the watch survives every subsequent renewal, not just the first.
routes.yml · vhostVirtual hosts

Wildcard vhosts #

A vhost entry beginning with *. is a pattern rather than a literal hostname, so one route can serve every subdomain of a parent domain without listing them.

routes.yml
vhosts:
  - vhost: ["*.test.example.com"]
    vhost_cert:
      cert: "/etc/proxyauth/cert/test.example.com/fullchain.pem"
      key: "/etc/proxyauth/cert/test.example.com/privkey.pem"
    routes:
      - prefix: "/"
        target: "http://127.0.0.1:8080"

What a pattern covers

Exactly one extra label. This is RFC 6125's rule — the same one browsers apply to certificate SANs — adopted deliberately so routing a hostname and holding a certificate for it can never disagree.

HostAgainst *.test.example.comResult
a.test.example.com
One label in place of the asterisk.
matches
test.example.com
The apex is a separate name. List it alongside the pattern to cover both.
no match
a.b.test.example.com
Two labels. A pattern never spans a dot.
no match
*foo.example.com
Only a leading *. is a pattern; anything else is read as a literal hostname.
literal

Precedence

An exact vhost always wins over a wildcard that would also match — for request routing, for resolving vhost-level settings, and for TLS certificate selection alike. SNI follows the same order: the exact name first, then patterns. Without this the winner would depend on where each route sits in routes.yml, which is the class of ambiguity route matching exists to remove.

The decision is made per request rather than once at startup, and that distinction matters for a mixed list. vhost: ["example.com", "*.example.com"] is an exact match when example.com is asked for, and a wildcard match when a.example.com is. A rank assigned to the route as a whole could not express both, so matching runs in two passes over the requested host instead.

routes.yml — the exact vhost wins
vhosts:
  # git.example.com reaches this one, even though the pattern below
  # also covers it — and regardless of the order they appear in.
  - vhost: ["git.example.com"]
    routes:
      - prefix: "/"
        target: "http://127.0.0.1:3000"

  # Everything else under example.com.
  - vhost: ["*.example.com"]
    routes:
      - prefix: "/"
        target: "http://127.0.0.1:8080"
Covering the apex too
List both in the same group — vhost: ["example.com", "*.example.com"]. They then share one vhost_cert and are issued a single certificate carrying both as SANs; see Let's Encrypt & ACME.
Certificates are the constraint
Routing a wildcard vhost needs nothing special. Obtaining a certificate for one does — Let's Encrypt issues wildcards only through DNS-01, which is manual here and excluded from automatic renewal. Read Wildcard certificates before putting one into production.
routes.yml · routeVirtual hosts

Narrowing a route to some of the group's hosts #

A group's routes serve every host the group declares. if_vhost restricts one route to a subset of them — so a single group can hold two routes at the same prefix, each answering a different host.

routes.yml — two roots, one group
vhosts:
  - vhost: ["example.com", "*.example.com"]
    vhost_cert:
      cert: "/etc/proxyauth/cert/example.com/fullchain.pem"
      key: "/etc/proxyauth/cert/example.com/privkey.pem"
    certbot_renew: true
    session_cookie: true

    routes:
      # the apex only
      - prefix: "/"
        if_vhost: ["example.com"]
        target: "http://127.0.0.1:8000"

      # every subdomain not declared elsewhere
      - prefix: "/"
        if_vhost: ["*.example.com"]
        target: "http://127.0.0.1:8080"

The certificate, the renewal flag and the session policy are written once. Without if_vhost the same result needs two full groups, with every vhost-level setting duplicated between them.

FieldDescriptionDefault
if_vhost1.2.7array
Hosts, from among those its group declares, that this route answers for. Each entry must be covered by the group's vhost — literally, or through one of its patterns.
[] — every host the group declares

if_vhost against vhost

Both fields can appear on a route and they answer different questions.

FieldWhat it doesChecked?
vhost
Declares the hosts a route serves. The historical form, and the only one available to a top-level route outside any group. Nothing relates it to anything else.
no
if_vhost
Filters among what the group already declared. Every entry is validated against that list at startup.
at startup
Why the check earns its place
An entry the group does not cover would have the route serve a host the group's vhost_cert does not name. The browser then refuses the connection before routing ever runs, and the error points nowhere near the typo that caused it. Startup stops instead, naming the route, the entry and the group's list.
Only inside a group
A top-level route has no group, so there is nothing to narrow — if_vhost there is refused at startup rather than silently ignored. Use vhost to declare what such a route serves.
Unset changes nothing
A route that does not set if_vhost inherits its group's vhost exactly as before. Every existing routes.yml keeps its current meaning.
routes.yml · vhostVirtual hosts

Per-vhost overrides #

Session, CSRF, redirect, SMTP and CORS settings from config.json can be set on a vhosts: entry in routes.yml instead, letting different domains on the same instance behave completely differently. A vhost that sets none of them falls through to the global default, so nothing changes for a vhost that does not need them.

FieldDescriptionDefault
session_cookieboolean
Whether ProxyAuth issues and checks a session_token cookie at all for this vhost, versus bearer-token-only auth.
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, which is the per-route opt-out once CSRF is already on somewhere.
global csrf_token
login_redirect_urlstring
Where an already-authenticated visitor is sent instead of the login form, and where a fresh login lands on success.
global value, or "/"
logout_redirect_urlstring
Where /logout sends the visitor afterward.
global value
login_via_otpboolean
Whether a TOTP code is required at login, in addition to username and password.
global value
page_change_passwordstring
The external page a password-reset link points visitors at, for this vhost's accounts.
global value
max_age_session_cookieinteger
The session cookie's Max-Age, in seconds. Also accepted as session_cookie_age.
global value
redirect_login_url1.3.0string
The login page for this vhost's required_login routes. See Login redirect.
logout_redirect_url
session_refresh_interval_secs
session_refresh_grace_secs
session_max_lifetime_secs1.3.0integer
This vhost's session renewal policy. session_refresh_interval_secs: 0 turns renewal off here while it stays on elsewhere. See Session renewal.
global values
refresh_token_expiry_seconds1.3.0integer
Refresh token lifetime for this vhost's API sessions; 0 turns them off here. See Refresh tokens.
global value
cors_originsarray
Origins allowed to make cross-origin requests to this vhost. Whole-list replacement, not merged with the global list.
global list
smtpobject
The SMTP server used to send this vhost's password-reset emails, letting different domains send through different mail servers. Whole-object replacement — host, port, credentials, from and timeout must all be set together.
global smtp
routes.yml — two vhosts, two mail servers
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"
Where to set these
These describe a domain rather than a path, so a vhosts: group is their natural home — set once, applied to every route under it. Individual routes accept the same fields when one of them needs to differ.
routes.yml · vhostVirtual hosts

Login authorization #

An earlier, different gate from the route-level username, groups and roles fields, which only govern what an already logged-in visitor can reach. This one decides whether a login attempt on a given vhost's /auth succeeds at all, before any session or route access enters the picture.

Explicit by design
A vhost accepts logins only from accounts it names. With allow_users, allow_groups and allow_roles all empty, no login succeeds — so adding a domain to a live instance does not hand it every account that exists elsewhere in the system. You open it by naming who belongs.

This is the opposite of the route-level default, where an empty list means "any authenticated account". The two answer different questions: this one decides who is vetted at all, the route decides how far a vetted visitor goes.
FieldDescriptionDefault
allow_usersarray
Usernames allowed to log in via this vhost. Combines with allow_groups and allow_roles as an OR.
[] — nobody
allow_groupsarray
Groups allowed to log in via this vhost, checked the same way route-level groups is.
[] — nobody
allow_rolesarray
Roles allowed to log in via this vhost, checked the same way route-level roles is.
[] — nobody
exclude_usersarray
Usernames explicitly denied login on this vhost, regardless of the three allow fields. An exclusion always wins, even over a direct username match or membership in an allowed group or role. For carving out an exception without restructuring the allow lists.
[]
routes.yml
vhosts:
  - vhost: ["app.example.com"]
    allow_groups: ["ops"]
    allow_roles: ["admin"]
    exclude_users: ["toto"]
    routes:
      - prefix: "/"
        target: "http://127.0.0.1:8000"

Anyone in the ops group or holding the admin role can log in — except toto, even if toto is also in ops or holds that role.

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 and 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

routes.yml · vhostOIDC provider

Overview & flow #

A vhost with an oidc: block turns ProxyAuth into a genuine OpenID Connect provider for the backend behind it. The backend — Grafana, Nextcloud, GitLab, anything that natively speaks OIDC as a relying party — receives a real, independently verifiable id_token via the standard authorization code flow, instead of relying on ProxyAuth's header injection or session cookie.

Note
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 do not set oidc:. Where it is set, the backend decides who is authenticated, not ProxyAuth's own required_login and access-control checks.

What changes on an OIDC-enabled vhost

ProxyAuth's own auth machinery steps aside entirely. Not just required_login, but CSRF validation and injection, the Authorization header forwarding decision, and route-level username, groups and roles access control too — none of them mean anything once the backend handles its own auth.

That last one matters most. Left unchecked, a route still carrying a groups: restriction from before it went OIDC-enabled would reject a session the OIDC flow had just correctly established.

This includes the global /auth and /logout endpoints that every other vhost gets: here, both proxy straight through to the backend, exactly like any other path. A relying party like Grafana has its own native login and logout UI, and it is that UI visitors reach on this vhost.

The six endpoints

What ProxyAuth does still serve on this vhost, all intercepted ahead of normal routing:

EndpointPurpose
GET /.well-known/openid-configurationDiscovery document
GET /oidc/jwks.jsonPublic signing key, for verifying tokens
GET, POST /oidc/authorizeWhere the browser lands to authenticate. GET shows the login step, POST is its own submission
POST /oidc/tokenServer-to-server code-for-token exchange
GET /oidc/userinfoClaims about the authenticated user
GET /oidc/end-sessionRP-Initiated Logout

Every endpoint except discovery lives under /oidc/, kept out of whatever the backend serves at its own root — rather than fixed names like /token sitting in the same namespace as the backend's own routes. Discovery is the one exception, fixed at the root by RFC 8414 rather than by ProxyAuth's choice.

Every other path on this vhost proxies straight through, untouched.

A user still authenticates the normal way

Nothing about how someone proves who they are changes — still ProxyAuth's own account store, file or database, with the same credential and TOTP verification as everywhere else.

What changes is when and how it is communicated: the login step happens directly at /oidc/authorize, which both renders the form on GET and processes its submission on POST, rather than detouring through the global /auth. And the proof that reaches the backend at the end 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 /oidc/authorize
(+ PKCE challenge) B->>OP: GET /oidc/authorize alt No valid ProxyAuth session OP->>B: Renders the login form directly B->>OP: POST /oidc/authorize
(username, password, TOTP) Note over OP: credentials verified, session established end OP->>B: Redirect to the redirect_uri
(+ authorization code) B->>RP: GET redirect_uri?code=... RP->>OP: POST /oidc/token
(code, client_secret, PKCE verifier) OP->>RP: id_token + access_token RP->>OP: GET /oidc/userinfo
(Bearer access_token) OP->>RP: claims — sub, email, name RP->>B: Session established, page loads

The login step only happens once per session. A returning visitor with a still-valid ProxyAuth session goes straight from GET /oidc/authorize to the redirect-with-code, with no login form in between.

Leave required_login unset here
On an OIDC vhost the backend owns the auth decision, so route-level session enforcement has nothing left to decide. Leave required_login off and let the relying party manage its own sessions — that is the division of responsibility the OIDC flow establishes.
routes.yml · vhostOIDC provider

Configuration #

One oidc: block per vhost, registering exactly one relying party. Deliberately one client per vhost rather than a list: ProxyAuth's model is already one vhost, one backend everywhere else, and matching that keeps the block answering a single question — which backend, at which callback URL, may 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"
      logout_redirect_uris:
        - "https://grafana.example.com/login"
      scopes: ["openid", "profile", "email"]
      login_page: "/etc/proxyauth/pages/login.html"
    routes:
      - prefix: "/"
        target: "http://127.0.0.1:3000"
FieldDescriptionDefault
client_idrequiredstring
The identifier the backend presents at /oidc/token, and that appears in tokens as the aud claim. Not secret — meant to be public, the same way a browser's client_id in a public OAuth flow is.
—
client_secret_hashrequiredstring
Argon2id hash of the client secret, verified the same way a user password is and never stored or compared as plaintext. See below for generating one.
—
redirect_urisrequiredarray
Exact-match only — no prefix or wildcard matching. Every redirect_uri a client sends to /oidc/authorize must appear here byte for byte. This is what guards against the classic OIDC open-redirect-via-authorization-code attack.
—
logout_redirect_urisarray
Same exact-match discipline, for RP-Initiated Logout's post_logout_redirect_uri. Also the fallback destination when a logout request does not specify one. See Logout.
[] — clears the session, never redirects
scopesarray
Scopes this client may request. openid is always implicitly required by the protocol regardless of what is listed.
["openid","profile","email"]
login_pagestring
Path to a static HTML file replacing the built-in login form. See Custom login page.
built-in form

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, so no length or memorability constraints apply — then hash it with Argon2id the way ProxyAuth hashes everything else. There is no dedicated CLI command for this yet; until there is, any small script using the same argon2 crate 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 speaking standard OIDC configures against the same URLs. Most need only the discovery URL and auto-configure the rest. Grafana wants each one spelled out:

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/oidc/authorize
token_url = https://grafana.example.com/oidc/token
api_url = https://grafana.example.com/oidc/userinfo
redirect_uri = https://grafana.example.com/login/generic_oauth
allow_sign_up = true
signout_redirect_url = https://grafana.example.com/oidc/end-session

allow_sign_up = true matters the first time any given account signs in — without it, Grafana refuses to create a local account for an identity it has not seen before, even though the OIDC handshake succeeded.

Verifying

shell
$ curl https://grafana.example.com/.well-known/openid-configuration
$ curl https://grafana.example.com/oidc/jwks.json

Both should return JSON — the expected URLs, and a public key respectively. The real end-to-end test is clicking "Sign in with ProxyAuth" on the backend's own login page and following the flow through.

HTTP APIOIDC provider

Endpoint reference #

Each check below is enforced in the order given. Every 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"]. Always Access-Control-Allow-Origin: *: any OIDC client library, from any origin, needs to fetch this to configure itself. The same wildcard applies to this endpoint's OPTIONS preflight, and a caller's cors_origins allow-list never applies here.
GET/oidc/jwks.json
ReturnsThe public half of the RS256 signing key, in JWKS format — what lets a standard OIDC client library verify a token without talking to ProxyAuth directly. Also always Access-Control-Allow-Origin: *; a public key is not a secret.
GET, POST/oidc/authorize
Paramsresponse_type=code, client_id, redirect_uri, scope, state, nonce, code_challenge, code_challenge_method=S256
Behaviorclient_id and redirect_uri are validated first and independently — an invalid one shows an error page directly, never a redirect, since that is the exact mechanism an open-redirect-via-OAuth attack relies on. GET either issues a code immediately, when a valid ProxyAuth session already exists, or renders the login step. POST is that same form's submission, verifying credentials and establishing the session before issuing the code. PKCE is mandatory, with no confidential-client exemption.
POST/oidc/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, SHA-256 of code_verifier, constant-time compared
Returns{"access_token", "token_type": "Bearer", "expires_in", "id_token"} — both tokens are signed RS256 JWTs with a one-hour lifetime
GET/oidc/userinfo
AuthAuthorization: Bearer <access_token> — no session-cookie fallback, this is meant to be called server to server
Returnssub always; email and email_verified only if the email scope was granted; name only if profile was granted
GET/oidc/end-session
Paramspost_logout_redirect_uri — optional, falls back to the first entry in logout_redirect_uris; state — optional, echoed back
BehaviorClears the ProxyAuth session cookie unconditionally, then redirects if the URI is on the exact-match list, otherwise shows a plain confirmation page. See Logout.

Authorization codes

Single-use, validated and consumed atomically in one LMDB read-write transaction — the same fix applied to ProxyAuth's own password-reset tokens after a real race condition was found there. Deliberately short-lived at 120 seconds: enough for the immediate browser redirect and the server-to-server exchange, not for anything else.

Token verification, precisely

id_token carries iss, sub, aud, exp, iat and nonce. The relying party's own OIDC library verifies it against /oidc/jwks.json, checking that aud matches its own client_id.

access_token additionally carries scope and deliberately omits aud: it is only ever verified by ProxyAuth's own /oidc/userinfo. That endpoint also checks the token's iss against the vhost the request actually arrived on, since every OIDC-enabled vhost currently shares one signing key.

CLIOIDC provider

Signing key setup #

The RSA keypair that signs every id_token is generated externally, once, before ProxyAuth ever starts with oidc: enabled anywhere. It is not something ProxyAuth generates for itself.

shell
$ openssl genrsa -traditional -out /etc/proxyauth/oidc/signing_key.pem 2048
$ chmod 600 /etc/proxyauth/oidc/signing_key.pem
$ chown <run_user> /etc/proxyauth/oidc/signing_key.pem

ProxyAuth only ever reads this file, never generates or rewrites it. If it is missing, or its permissions are not exactly 600, ProxyAuth refuses to start with a clear message giving these same commands rather than a stack trace.

Detail
-traditional matters — ProxyAuth expects PKCS#1 (-----BEGIN RSA PRIVATE KEY-----), not PKCS#8. The public modulus and exponent needed for /oidc/jwks.json are read out with a small, purpose-built DER reader, narrow enough to be fully verified against real openssl-generated keys rather than pulling in a general-purpose RSA crate for two fields.

A build that can sign

Signing an id_token needs a crypto provider compiled into the jsonwebtoken crate. Version 11 of that crate enables none by default, and panics the moment it is asked to sign — on POST /oidc/token, which the relying party sees as a dropped connection. One line in Cargo.toml settles it:

Cargo.toml
jsonwebtoken = { version = "11", features = ["aws_lc_rs"] }

aws_lc_rs is already in the dependency tree, through rustls and the ACME client. Its alternative, rust_crypto, also works but brings back the rsa crate and with it RUSTSEC-2023-0071, which this code deliberately avoids.

Since 1.3.0, ProxyAuth signs a probe token while loading the key: a build without a provider is refused at startup, with that same line in the message, instead of failing on the first relying party.

Every OIDC-enabled vhost currently shares this one key. There is no per-vhost key rotation or support for multiple simultaneous keys yet.

routes.yml · vhostOIDC provider

Customizing the login page #

The built-in login form is unstyled but fully functional — nothing needs configuring to get OIDC working. Setting oidc.login_page swaps it for a static HTML file rendered with three context-specific tags, in addition to the general-purpose tags covered in Dynamic tags.

TagDescriptionNotes
{{ form }}
A complete, ready-to-use <form>: correct submit target, a CSRF field if enabled, username and password inputs, the TOTP field if enabled for this vhost, and a submit button. Drop it in and it works.
easiest path
{{ form_totp }}
Just the TOTP input on its own — an empty string if TOTP login is not enabled. For hand-building a custom form instead of using {{ form }} wholesale.
custom form
{{ auth_action }}
Just the dynamic form submit target, for a hand-built <form method="POST" action="{{ auth_action }}">.
custom form

Same form, your own branding

login.html
<!DOCTYPE html>
<html><body>
  <img src="/logo.svg">
  {{ form }}
</body></html>

Two-step UX, one form and one POST underneath

login.html
<form method="POST" action="{{ auth_action }}">
  <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
  <input type="text" name="username">
  <input type="password" name="password">
  <button type="button" onclick="showTotp()">Next</button>

  <div id="totp-step" style="display:none">
    {{ form_totp }}
    <button type="submit">Sign in</button>
  </div>
</form>
<script>
function showTotp() { document.getElementById('totp-step').style.display = 'block'; }
</script>

TOTP is still verified alongside username and password in one request server-side. The reveal is presentation only, not a separate login step ProxyAuth is aware of.

Note
If login_page is set but cannot be read — missing file, wrong permissions — /oidc/authorize logs the error and falls back to the built-in default rather than failing the request outright.
routes.yml · vhostOIDC provider

Logging out #

The global /logout proxies through to the backend on an OIDC-enabled vhost, the same as /auth. Ending a ProxyAuth session therefore needs its own dedicated endpoint, advertised as end_session_endpoint in the discovery document, implementing RP-Initiated Logout.

example request
GET /oidc/end-session
    ?post_logout_redirect_uri=https://grafana.example.com/login
    &state=abc123

ProxyAuth clears its own session cookie unconditionally. If post_logout_redirect_uri is present and matches logout_redirect_uris exactly, the browser is redirected there with state echoed back.

If it is absent entirely, ProxyAuth falls back to the first entry in logout_redirect_uris — so a backend's sign-out configuration can point at /oidc/end-session with no query string and still land somewhere sensible. A present-but-unrecognized URI shows a plain confirmation page instead of guessing.

Detail
id_token_hint, the other parameter the RP-Initiated Logout spec defines, is deliberately not accepted. It is the relying party's own previously issued token, not a ProxyAuth one, so there is no reliable way to map it back to a session to revoke server-side. The cookie is cleared either way; a still-technically-valid token that is simply no longer sent anywhere is the accepted trade-off, the same class of gap ordinary cookie expiry already has.
grafana.ini — relying on the fallback
[auth.generic_oauth]
signout_redirect_url = https://grafana.example.com/oidc/end-session

routes.yml · routeMiddleware

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.

FieldDescriptionDefault
usernamearray
Usernames allowed to reach this route.
[] — any account
groupsarray
Groups allowed to reach this route, checked against the account's groups.
[] — any account
rolesarray
Roles allowed to reach this route, checked against the account's roles.
[] — any account
need_csrfboolean
Whether this specific route requires a valid CSRF token, once CSRF is already enabled for the vhost. Independent of, and one level below, tag_csrf_token.
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 it.

The decision is a single function, not a boolean — it reports why access was granted: by username, by a named group, by a named role, or because the route is open. That is what lets routes-audit and check-access explain themselves, and it guarantees those tools cannot drift from what the proxy actually enforces, since both call the same code.

Two gates, two defaults
These lists and the vhost's login authorization lists answer different questions, and each has the default its question calls for.

Here, empty means any authenticated account: the visitor already passed login, and a route that names nobody is simply not narrowing further.
On a vhost, empty means no login: nobody has been vetted yet, so access is granted by naming who belongs.
routes.yml
routes:
  - prefix: "/admin"
    target: "http://127.0.0.1:8000"
    groups: ["ops"]
    roles: ["admin"]
routes.yml · vhostMiddleware

Maintenance mode #

redirect_protect is a different kind of gate from access control: it runs before authentication is even considered, as early in request handling as possible — after IP blocklisting and ACME challenge handling, before everything else. It is not about who is logged in, but about who reaches this route's real content at all right now.

routes.yml — minimal
vhosts:
  - vhost: ["app.example.com"]
    redirect_protect:
      allow_ip: ["203.0.113.42"]
      path: "/var/www/maintenance/index.html"
    routes:
      - prefix: "/"
        target: "http://127.0.0.1:8000"
FieldDescriptionDefault
allow_iparray
The only IPs and CIDR ranges allowed this route's normal content. Everyone else gets the maintenance response with a 503 status. Same IP/CIDR matching as elsewhere, IPv6 included. Leaving it empty is genuine maintenance-for-everyone — no need to spell out a catch-all.
[] — everyone redirected
allow_url_ipsobject
A remote or local source of additional allowed IPs, merged with allow_ip. Exactly the same shape as an ip_blocklists entry — source, name, csv, csv_column — refreshed on redirect_protect_refresh_interval_secs rather than the blocklist interval, so a maintenance allow-list can be refreshed on a different cadence from a general abuse feed. A fetch failure falls back to the last successful result rather than going empty.
—
deny_url_ipsobject
Same source shape, opposite direction. An IP on this list is redirected even if the allow lists would let it through. Checked first — deny always wins. Useful for "allow this whole office range except the one machine separately known to be compromised".
—
pathstring
Absolute path to the static file served to a blocked visitor — any content type guess_content_type recognizes, not just HTML. Read fresh on every matching request, never cached, so editing it takes effect immediately.
—
target1.2.1string
Backend URL to proxy a blocked request to instead of serving path — original method, headers and body all forwarded. Tried first when set, falling back to path only if reaching it fails: connection error, timeout, invalid URL. For a maintenance page or status service hosted somewhere else entirely.
—
redirect_url1.2.1string
Where a real 303 See Other sends a visitor when a paths rule's session check fails — an actual Location header, not a proxied response. Shared across every rule in paths. Only applies to a paths failure, never to the allow_ip check.
—
paths1.2.1array
Extra, path-scoped session gates layered on top of the IP check — not a replacement for it. See Session-checked paths.
[]

Admin bypass — the classic use

routes.yml
redirect_protect:
  allow_ip:
    - "203.0.113.42"      # a single admin IP
    - "10.0.0.0/8"        # or a whole office network
  path: "/var/www/maintenance/index.html"

Fetched allow and deny lists

Allowing an externally published range instead of listing every IP by hand — a team's dynamic VPN egress list, say — with a separate feed excluding one address from it:

routes.yml
redirect_protect:
  allow_url_ips:
    source: "https://vpn.example.com/egress-ips.txt"
  deny_url_ips:
    source: "https://example.com/known-compromised.txt"
  path: "/var/www/maintenance/index.html"

Set redirect_protect_refresh_interval_secs globally to control how often every route's sources are re-fetched. 0 fetches once at startup only.

Proxied maintenance page

routes.yml
redirect_protect:
  allow_ip: ["203.0.113.42"]
  target: "http://backup-status.example.com"
  path: "/var/www/maintenance/index.html"   # fallback if target is unreachable
Safe
Certificate renewal is never affected by this gate — ACME challenge handling sits ahead of it in the request lifecycle. Maintenance mode cannot block a certificate from renewing while it is active.

Set on a vhosts: group, the gate covers every route under it at once — which is what a maintenance window usually calls for. Set on a single route, it covers that prefix alone.

routes.yml · vhostMiddleware

Session-checked paths #

An IP allow-list is all-or-nothing per route. paths adds a second, independent layer: specific sub-paths, matched by regex, that also require a genuinely valid backend session — verified by asking the backend itself, not by inspecting ProxyAuth's own session_token.

That indirection is deliberate. It works the same way regardless of what the backend's auth scheme actually is, including on an OIDC-enabled vhost where ProxyAuth's session cookie is not the authority to begin with.

routes.yml — protecting a git forge's source browser
redirect_protect:
  allow_ip: ["203.0.113.0/24"]
  redirect_url: "https://git.example.com/user/login"
  paths:
    - regex: '^/[^/]+/[^/]+/src(/.*)?$'
      check_path: "/notifications"
      type_return: "status_code"
      expected_status: 200

For every request whose path matches regex, ProxyAuth issues a real request to this route's own backend at check_path, forwarding the original Cookie header unchanged, and interprets the response the way type_return says to.

FieldDescriptionDefault
regexrequiredstring
Matched against the request path the same way a route's own regex is — searched anywhere in the path by default. Add ^ and $ for an exact match.
—
check_pathrequiredstring
A path on this route's own backend to probe, e.g. "/api/user". Always appended to the route's target, never to the matched request path — the same fixed check runs no matter which regex matched.
—
type_return"status_code" | "json"
How to interpret the response. status_code is usually enough on its own: a session-protected endpoint typically already returns 401, or redirects to a login page, neither of which is 2xx. Reach for json only when a bare 2xx is not precise enough.
"status_code"
expected_statusnumber
Only used with status_code. The exact status that counts as valid. Unset accepts any 2xx; set it to require precisely that code — 200 and not 204.
any 2xx
expected_fieldstring
Only used with json. A top-level field to look up in the response body — "authenticated" for {"authenticated": true}. Required in json mode: a rule set to json with no field never passes.
—
expected_valuestring
Only used with json, alongside expected_field. The value that field must equal, compared against its natural string form — a JSON string by its content, a bool or number by its usual display form. Left unset, the field's mere presence with a scalar value is enough.
presence only

JSON field check

A stricter alternative for a backend whose logged-out response happens to also be a 2xx:

routes.yml
paths:
  - regex: '^/billing(/.*)?$'
    check_path: "/api/user"
    type_return: "json"
    expected_field: "authenticated"
    expected_value: "true"
Note
When a check fails, redirect_url — if set — sends a real 303 See Other and the browser navigates there; nothing is proxied. Without it, a failed check falls through to the same target/path resolution the IP check uses. This is the default behavior for a paths entry, unless it carries hidden_blocks, which changes it entirely.
routes.yml · vhostMiddleware

Hidden blocks #

Nested inside a paths entry, hidden_blocks reuses that entry's own check — but instead of redirecting or blocking the whole path on failure, it lets the request through untouched and, once the real response comes back, finds and strips one specific HTML element from it.

routes.yml
redirect_protect:
  paths:
    - regex: '^/dashboard(/.*)?$'
      check_path: "/api/user"
      type_return: "json"
      expected_field: "role"
      expected_value: "admin"
      hidden_blocks:
        - html_tag: '<div id="admin-panel" class="widget">'
          fallback_html: "<p>You don't have permission to view this.</p>"
FieldDescriptionDefault
html_tagrequiredstring
The exact opening tag, copied verbatim from the page's own HTML — attributes, order, quoting, all of it. ProxyAuth searches for this literal text, works out the tag name from it (any tag works, not just <div>), and finds the matching closing tag itself, correctly skipping past nested elements of the same name. If the exact text is not present in the response, the rule is a silent no-op.
—
fallback_htmlstring
HTML to put in the element's place when the check fails, instead of removing it outright — a permission message, an empty placeholder, anything.
remove outright

The check itself is never repeated per element: one backend request per matching paths entry, its result applied to every hidden_blocks rule under it.

Important
Adding even one hidden_blocks rule switches that paths entry's job entirely, from "gate this path" to "let it through and hide this piece of it". An entry with no hidden_blocks keeps gating the whole path as described in Session-checked paths.
Requires
tag_proxyauth: true on the route, or this never runs. hidden_blocks is applied on the exact same response-scanning pass as ProxyAuth's {{ }} tag substitution, which only happens on routes that opted in.
Fails closed
The gate resolves to "blocked" in every uncertain case: a visitor whose IP cannot be determined is treated the same as one not on the list; a check_path request that times out, fails to connect, or returns something unexpected is treated as not authenticated; and if neither target nor path resolves to anything usable, the visitor gets a bare 503. In every branch the gate holds, and the route's real content stays behind it.
config.jsonroutes.yml · vhost + route

CSRF #

Three layers, each answering a different question. All three are combined with logical AND at the actual check — turning any one off is enough to skip both injection and validation for that scope.

SettingQuestion it answersDefault
csrf_tokenboolean · global
Is CSRF protection on anywhere at all? The master switch in config.json.
true
tag_csrf_tokenboolean · vhost
Is it on for this vhost? Injection and server-side validation together. See Per-vhost overrides.
global value
need_csrfboolean · route
Does this route participate, given it is already on for the vhost? See Access control.
true

The full condition: session cookies on, and CSRF resolved-enabled for the vhost, and this route requires it.

Where the token gets filled in

Two independent mechanisms substitute {{ csrf_token }} into HTML, and either can apply to the same response:

  • The CSRF-specific mechanism — always active on proxied responses once session_cookie and CSRF are on for the vhost, regardless of tag_proxyauth.
  • The tag_proxyauth mechanism — also fills {{ csrf_token }}, on top of the other three tags, on static files and proxied responses alike. It respects tag_csrf_token too: off means the tag is left as literal text there as well.

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 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 — critical on a multi-vhost instance, where more than one vhost commonly has its own route at the exact same path.

routes.yml · routeMiddleware

Dynamic tags #

Four template tags, substituted server-side in static files and proxied responses alike, gated per route by tag_proxyauth: true. Both spaced ({{ username }}) and unspaced ({{username}}) forms are recognized.

TagDescriptionWhen unavailable
{{ username }}
The signed-in visitor's username, checked independently of whether the current route requires login — so a public page can still greet an already-authenticated visitor.
left as literal text
{{ csrf_token }}
A freshly generated, signed CSRF token — the same one /auth submissions are checked against. Also needs CSRF resolving to enabled.
left untouched, not filled with a token nobody will check
{{ proxyauth_version }}
The running build's version.
always resolves
{{ proxyauth_id }}
The running build's instance ID.
always resolves

Why tag_proxyauth is opt-in

Off by default, and — unlike most per-route settings — not inherited-then-defaulted-on anywhere. Substitution reads the whole response body as text and scans it on every matching request, so it is enabled per route, for exactly the content that uses these tags. Only text/html responses are scanned even where it is 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
        cache: false
      - 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 }} stays literal text since there is nobody to greet, {{ csrf_token }} is filled with a real token so the form works, and {{ proxyauth_version }} resolves regardless of login state.

Pair with cache: false
Substituted content is per visitor: {{ username }} names the current one and {{ csrf_token }} is generated for the current request. Setting cache: false on the same route sends no-store, so each visitor gets their own render. See Caching.
routes.yml · vhost + routeMiddleware

Response headers #

Arbitrary headers — CSP, HSTS, X-Frame-Options, anything else — added to every response a route produces, proxied and static alike.

routes.yml
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 entries win only on a key both define. That lets you set HSTS once for a whole vhost, then override one header on a specific route.

Two behaviors to know
A header name or value that is not valid for HTTP is skipped and logged; the rest of the set is applied and the response is served normally.

Headers apply to this route's own responses. Rejections that happen before a route is matched — an IP blocklist 403, a CORS preflight refusal — are not this route's content and do not carry them.

What happens to a malformed entry

A name or value that is not valid in HTTP — a typo in routes.yml, a stray newline — is skipped, with a warning naming the route and the header. It is not passed on to the response builder, because a rejected header there turns the whole response into a 500: one bad line in a configuration file should cost that line, not the route.

Where they do and do not apply

ResponseCustom headersWhy
proxied and static routes
Applied, replacing whatever the backend set under the same name.
yes
before a route matched
A blocklist 403, a CORS refusal, a 404: there is no route yet, so there are no route headers to apply.
no
hop-by-hop names
Connection, Transfer-Encoding, Upgrade and the rest are regenerated per hop — see Hop-by-hop headers.
not yours to set
Cache-Control
Overridden on a response carrying a session cookie, or one served to a signed-in visitor — see Caching.
conditionally
Security headers belong on the vhost
Strict-Transport-Security, Content-Security-Policy and X-Frame-Options are properties of a site, not of one route. Set them once on the vhosts: group and override only where a page genuinely needs something different — an admin area that must never be framed, say.
routes.yml · routeMiddleware

Compression #

gzip, deflate and brotli, negotiated per request via Accept-Encoding. Configurable per route: algorithm, level, and the minimum size before compressing at all.

routes.yml
routes:
  - prefix: "/"
    target: "http://127.0.0.1:8000"
    compression:
      enabled: true
      algorithm: "br"
      min_size: 1024
      max_size: 10485760
      types: ["text/html", "application/json"]
FieldDescriptionDefault
enabledboolean
Whether compression applies at this level at all.
inherit
algorithmstring
gzip, deflate or brotli, still subject to the client's own Accept-Encoding.
inherit
min_size / max_sizeinteger · bytes
The size range eligible for compression. Responses below the minimum are sent uncompressed, where the CPU cost would exceed the bytes saved.
inherit
typesarray
Content types eligible for compression.
inherit

Every field is individually optional, so a route can override just enabled: false — or just algorithm — and inherit the rest from its vhost group, then from the global compression block.

Note
Never applied to a response already compressed by the backend, or to one already carrying a Content-Encoding from the CSRF or tag injection mechanisms — those re-compress with whatever the backend originally used instead, to avoid double-encoding.

Choosing the algorithm and the level

algorithm accepts a single value or a preference list, most-preferred first — "br, gzip, deflate". It is filtered against the client's Accept-Encoding, and a client that expresses its own preference with q-values wins: the list says what you would rather send, not what you impose.

FieldDescriptionDefault
levelinteger
Compression level for content generated on the fly. Clamped per algorithm — gzip and deflate accept 0 to 9, brotli 0 to 11 — so a level meant for brotli cannot fail a gzip response.
built-in
level_static · file_staticinteger · list
A second level for files whose URL ends with one of the listed extensions (".js" or "js", case-insensitive). Static content can afford a slower, denser setting. With no list, everything uses level.
use level
typeslist
Replaces the built-in allow-list of compressible media types. Entries match as prefixes of Content-Type, so "text/" covers every text subtype. Images, video, audio and already-compressed formats stay excluded regardless.
built-in list
upstream_identityboolean
Asks the backend for an uncompressed body (Accept-Encoding: identity) so this block actually applies — the equivalent of nginx's proxy_set_header Accept-Encoding "". On by default when compression is enabled, because a backend that compresses on its own would otherwise make the whole block look broken. Turn it off to leave the backend in charge, which is worth doing when it caches its compressed output.
on with compression

What is never compressed

  • A body the backend already compressed. A response arriving with Content-Encoding is passed through untouched — no double compression.
  • Anything outside the size window. When the length is known, the decision is taken from the header, without reading the body at all; below min_size the encoding overhead costs more than it saves, and max_size is there for the occasional very large export whose CPU cost you would rather skip.
  • Media types outside the allow-list, images and video included: they are already compressed.

Every field is optional and inherited: a route falls back to its vhosts: group, then to the global compression block in config.json. Set it once at the top and override only where a route needs something different.

routes.yml · vhost + routeMiddleware

Streamed responses #

By default a proxied response is collected in full before a single byte reaches the client. streaming: true relays the body as it arrives instead.

The cost of buffering is the size of the response multiplied by the number of concurrent requests — the exact shape of a git server, a package registry, or any download endpoint.

routes.yml
vhosts:
  - vhost: ["git.example.com"]
    streaming: true
    routes:
      - prefix: "/"
        target: "http://127.0.0.1:3000"
FieldDescriptionDefault
streaming1.2.5boolean
Relay this route's responses as a stream instead of buffering them. Settable on a vhosts: group and inherited by every route under it, or on a single route.
false

What changes

AspectBuffered — the defaultStreamed
Memory
The whole response is held, per concurrent request. A 300 MB packfile is 300 MB resident for as long as the transfer runs.
one chunk
Time to first byte
Equal to the duration of the whole transfer — the client sees nothing, then everything at once.
immediate
Open-ended responses
Server-sent events and long-polling never complete, so collecting them waits for an end that does not come.
works
Framing
A known Content-Length is emitted.
chunked

When buffering wins anyway

Three features rewrite the response body and therefore need to see it whole. When any of them is active on a route, that route buffers regardless of streaming, and a warning naming the route is logged at startup — silently dropping a substitution an operator explicitly configured would be the worse failure.

FeatureWhy it needs the whole bodyResult
Substitutes {{ username }} and the other tags anywhere in the document.
buffers
Finds an opening tag and its matching close, which can be arbitrarily far apart.
buffers
Rewrites forms in the returned HTML and recomputes the content length.
buffers

The split is clean in practice: HTML served to a browser is small and often wants rewriting, while git traffic, package downloads, API responses and event streams want neither rewriting nor buffering.

Request bodies
This setting concerns the response. Request bodies are still read in full before the handler runs, bounded by max_body_size — relevant for a large git push, not for a clone.
Timeouts
backend_timeout covers the arrival of response headers, not the transfer of the body. A streamed route will not cut a long download short — and equally will not time out a backend that sends its headers and then stalls.
routes.yml · routeMiddleware

Caching #

FieldDescriptionDefault
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.
true
cache_duration_secsinteger
When cache is on, the max-age sent in Cache-Control: public, max-age=… for this route. Falls back to the global cache_duration_secs.
global value

Signed-in visitors

The route's policy applies to anonymous visitors. Two cases override it, whatever the route or the backend asked for:

ResponseWhyCache-Control
carries a session cookie1.3.0
A renewed or cleared session_token. A shared cache — a CDN, a corporate proxy — would hand this visitor's session to the next one, and a browser cache would replay the page without the cookie. Expires is removed and Pragma: no-cache added.
private, no-store, max-age=0
a page, for a signed-in visitor1.3.0
An HTML document answered to a request carrying a valid session cookie. Kept out of shared caches, and revalidated by the browser before reuse — a page served from the browser's cache never reaches ProxyAuth, so session renewal could not happen on it. A stricter no-store set by the route is kept.
private, no-cache
an asset, for a signed-in visitor1.3.0
Scripts, stylesheets, images, fonts, API answers: they keep the route's cache_duration_secs, with public downgraded to private so a CDN or a company proxy never serves one signed-in visitor's file to another. Only the page has to be revalidated; making every asset revalidate too would empty the browser cache of anyone signed in.
private, max-age=…
anonymous
The route's own policy, unchanged.
cache / cache_duration_secs
Per-visitor content
Routes serving a login page, or anything with tag_proxyauth: true, render differently for each visitor, signed in or not. Pair them with cache: false.
routes.yml · routeMiddleware

Request filters #

Regex conditions evaluated before forwarding, against the method, path, a header, a query parameter, or the request body. Every condition in allow must match — they combine as an AND, not an OR. A request that fails any of them gets a 403.

routes.yml
routes:
  - prefix: "/webhook"
    target: "http://127.0.0.1:9000"
    filters:
      default_allow: false
      allow:
        - field: method
          pattern: "^POST$"
        - field: header
          name: "^x-webhook-secret$"
          pattern: "^expected-value$"
Shape
Each entry is tagged by a field key, not written as method: POST directly. And every pattern is a regular expression, not a literal — pattern: "POST" matches any method containing that substring. Anchor with ^ and $ unless you mean otherwise.

Condition types

fieldMatches againstExtra key
method
The HTTP method.
—
path
The request path, canonicalised first — so /a/../b is matched as /b and cannot be used to slip past a pattern.
—
header
A header's value. name is itself a regex matched against header names, not a literal name.
name
query
A query parameter's value. name is a regex over parameter names, and a parameter repeated in the query string is matched across all its values.
name
body_raw
The raw body as UTF-8. A body that is not valid UTF-8 simply never matches.
—
body_json
A top-level JSON field's value. Only parsed when the request's Content-Type actually contains application/json — otherwise the condition never matches, regardless of what the body holds.
key

default_allow

FieldDescriptionDefault
default_allowboolean
Only consulted when allow is empty. With conditions present, the AND over them is the whole decision and this value is never read. An empty allow with default_allow: false is therefore a way to block a route outright.
true
allowarray
The conditions, all of which must match.
[]
Cost
The body is only read as UTF-8, or parsed as JSON, when at least one condition actually asks for it. A filter set using only method and path never touches the body at all.

routes.yml · routeBackends

Load balancing #

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.yml
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 — no traffic unless the others are all down
FieldDescriptionDefault
urlrequiredstring
The backend's URL.
—
weightinteger
Relative share of traffic — weight 2 against weight 1 means roughly twice 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 retried immediately on every subsequent request, avoiding a thundering herd of failed attempts against something that is genuinely down.

How a backend is chosen

Not a plain round-robin. Weights are spread with smooth weighted round-robin: with weights 5 and 1 the light backend is used once every six requests, evenly, rather than receiving five in a row every sixth cycle. A backend at weight: -1 is left out of the rotation entirely and only tried when every weighted backend has failed.

On top of that, the backend that last answered for a given route, method and host is reused for two seconds. That keeps a page and the requests it triggers on the same instance, which matters for a backend holding any per-node state, and costs nothing when there is none. The entry is checked against the route's current backend list before it is used, so a changed configuration can never send traffic somewhere no longer listed.

When a backend fails

BehaviorDetailValue
retry on the next backend
Only for methods that can be repeated without changing anything twice: GET, HEAD, PUT, DELETE, OPTIONS. A failed POST is reported rather than silently replayed somewhere else.
idempotent methods
cooldown
A backend that just failed is skipped for a while instead of being retried on every request — otherwise a machine that is simply down collects one doomed attempt per request. The delay grows with consecutive failures and is capped.
2 s per failure, at most 5 s
forgiveness
A backend that has not failed for a while is forgotten from the cooldown table and treated as new again. A success clears its record immediately.
10 s
all of them down
Backends at weight: -1 are tried, then the request fails.
502
A multi-backend route buffers its responses
Retrying is only possible while nothing has been sent to the client, so the failover path collects a backend's response before deciding to forward it or move on. The body is bounded by max_body_size, the same ceiling used for request bodies. For a route carrying large downloads or event streams, prefer a single target with streaming: true — or put the load balancing in front of ProxyAuth.

Connection tuning

Shared with every proxied route, from config.json: client_timeout bounds one attempt against a backend, keep_alive how long an idle connection is kept, and max_idle_per_host how many are kept per backend. backend_timeout bounds the exchange as a whole.

routes.yml · vhost + routeBackends

Forwarding client info #

A backend behind ProxyAuth normally sees ProxyAuth itself as the client — its address, not the real visitor's. forward_proxy_headers: true adds the standard set of headers a backend needs to recover the original request, the same information nginx's proxy_set_header directives provide.

routes.yml
vhosts:
  - vhost: ["app.example.com"]
    forward_proxy_headers: true
    routes:
      - prefix: "/"
        target: "http://127.0.0.1:8000"
HeaderValue sentnginx equivalent
Host
Rewritten to the original vhost's hostname, not left as the backend's own address.
$host
X-Forwarded-Host
The same original hostname, for a backend that looks here rather than at Host.
$host
X-Forwarded-Proto
https or http, reflecting how the original request actually arrived.
$scheme
X-Real-IP
X-Forwarded-For
The visitor's real IP — always ProxyAuth's already-resolved, trusted client IP, the same resolution trust_proxy_forward_for governs elsewhere. Never a blind copy of whatever the client sent.
$remote_addr
Safe
A client-supplied version of any of these five headers is always stripped before substitution, regardless of this setting. A backend that trusts them cannot be fed a spoofed value by simply asking.
Note
Unset means false — off by default. A backend already receiving these values from something else — a TLS-terminating load balancer in front of ProxyAuth, for instance — keeps them as they are until you opt in.
BehaviorBackends

Headers the proxy never relays #

Ten headers are stripped from every request before it reaches the backend, and from every response before it reaches the client. They describe one connection, not the message travelling over it, so copying them across a proxy boundary is meaningless at best.

This is not configurable. A proxy that relays them is a proxy two HTTP implementations can be made to disagree about — which is the entire mechanism behind request smuggling.

stripped in both directions
Connection            Keep-Alive
Content-Length        Proxy-Connection
Transfer-Encoding     Proxy-Authenticate
TE                    Proxy-Authorization
Trailer
Upgrade

RFC 9110 §7.6.1 calls these connection-specific. Each hop is expected to generate its own framing based on how it actually sends the message, not to copy what the previous hop said. ProxyAuth re-serialises every body it forwards, so it emits its own Content-Length or chunked framing and discards whatever arrived.

What this protects against
A request carrying both a Content-Length and a Transfer-Encoding, relayed verbatim, can be read as one request by the proxy and as two by the backend — the second one smuggled, never seen by any access check. Stripping the framing headers and regenerating them removes the disagreement rather than trying to detect it.

The one exception

A HEAD response keeps the backend's Content-Length. The body is empty by definition, so regenerating the header would produce 0 — but a client issuing HEAD is asking precisely how large the matching GET would be. Both proxy paths do this identically, so HEAD never answers differently depending on whether an egress proxy is in play.

WebSocket does not pass through
A WebSocket handshake is an HTTP request carrying Connection: Upgrade and Upgrade: websocket. Both are on the list above, so they are removed and the backend sees an ordinary request it will not upgrade. The connection never becomes a WebSocket.

There is no setting for this — supporting it means handling the upgrade rather than passing the headers along. Keep a WebSocket endpoint behind something that terminates the upgrade itself, on its own hostname or port, and route the rest through ProxyAuth normally.

Headers stripped for a different reason

Four more never reach the backend as the client sent them, not for framing but because ProxyAuth asserts them itself: X-User, X-User-Roles, X-User-Groups and, when forward_proxy_headers is on, X-Forwarded-Host, X-Forwarded-Proto, X-Real-IP and X-Forwarded-For.

A backend that trusts those headers cannot be fed a chosen value by a caller who simply sets them — see Sessions and Forwarding client info.

Content-Encoding too
The client's Content-Encoding is not forwarded either. It describes the body as it was received, and the body reaching the backend has already been decoded — announcing an encoding the bytes no longer carry makes the backend fail to decode them. This is what made git clone fail against a proxied git forge, since git gzips its upload-pack request.
routes.yml · routeBackends

Client certificates (mTLS) #

ProxyAuth can present its own client certificate when connecting to a backend that requires mutual TLS. The backend authenticates ProxyAuth itself, independently of whatever authenticates the original visitor.

routes.yml
routes:
  - prefix: "/secure-api"
    target: "https://internal-service:8443"
    cert:
      file: "/etc/proxyauth/certs/client.crt"
      key: "/etc/proxyauth/certs/client.key"

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 and never accidentally share a connection authenticated as the wrong identity.

Choosing how certificate errors surface

A certificate that cannot be read, or that fails to pair with its key, is handled one of two ways. strict_mtls in config.json picks which.

SettingOn a certificate errorSuits
strict_mtls: falsedefault
The connection is made without client authentication and a warning is logged. Traffic keeps flowing to backends that accept unauthenticated callers, and the warning records which route and certificate to look at.
availability first
strict_mtls: true
The request fails with 502. A backend that relies on mTLS to identify ProxyAuth gets an immediate, visible signal the moment a certificate path changes.
certainty first

The two files

A PEM certificate chain and its PEM private key — the pair an internal CA hands out — read by rustls:

KeyContentNotes
file
The client certificate, and any intermediates, in one PEM file — -----BEGIN CERTIFICATE----- blocks, leaf first.
required
key
The matching private key, unencrypted: PKCS#8 (BEGIN PRIVATE KEY), PKCS#1 (BEGIN RSA PRIVATE KEY) or SEC1 (BEGIN EC PRIVATE KEY). The first key in the file is used.
required

Both paths must be readable by run_user, and the key by nobody else. There is no passphrase: an encrypted key has to be decrypted first — openssl rsa -in encrypted.key -out client.key. Leaving either path out, or pointing at an unreadable file, is treated as a certificate error (below).

The certificate has to be X.509 v3
rustls refuses a v1 certificate with UnsupportedCertVersion, and openssl x509 -req produces v1 unless it is given extensions. Sign client certificates with an extension file:
client.cnf, then openssl
basicConstraints = CA:FALSE
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = clientAuth

$ openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key \
    -days 365 -out client.crt -extfile client.cnf
openssl x509 -in client.crt -noout -text | head -3 shows the version.
The backend's own certificate
Its chain is verified against the system trust store, and only that — there is no per-route CA bundle. A backend using a certificate from a private CA needs that CA installed on the host, for example in /usr/local/share/ca-certificates/ followed by update-ca-certificates.

When the certificate cannot be used

strict_mtlsMissing path, unreadable or empty file, key and certificate that do not pairSuits
falsedefault
The client falls back to no client authentication, and the problem is logged as a warning. The route keeps working against a backend that also accepts unauthenticated callers — and silently loses the identity it was supposed to present.
availability first
true
The request fails with 502 and a loud log line. Nothing reaches the backend without the identity it expects.
anything relying on mTLS

It is a global setting in config.json. If a backend uses the certificate to decide who is calling, turn it on: a renamed file should be a failed request, not a request that quietly arrives as nobody.

What the fallback looks like
With strict_mtls: false, a backend that requires a client certificate closes the handshake on the unauthenticated connection, and the request ends as 503 — not as an obvious certificate error. The reason is one line above it in the log, TLS: clé vide ou invalide, fallback sans client auth or paire cert/key invalide. With strict_mtls: true the same misconfiguration answers 502 immediately, which is easier to act on.

A client is built once per certificate and key pair and cached, so a changed certificate is picked up at the next restart, not on the next request.

Two identities, two questions
mTLS answers "is this ProxyAuth?", and says nothing about the visitor. The visitor's identity travels in X-User, on the same connection. A backend can require both: the certificate to accept the connection at all, the headers to know whose request it is carrying.
routes.yml · routeBackends

Path handling #

FieldDescriptionDefault
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: with target: http://localhost:8000/api/endpoint, a request for /api/endpoint/../other still forwards to exactly /api/endpoint.
false
FieldDescriptionDefault
preserve_prefixboolean
By default the matched prefix is removed before forwarding: a route on /grafana sends /grafana/d/abc to the backend as /d/abc, which is what an application that does not know it is mounted under a path expects. Set this to keep the prefix, for a backend configured to live under it.
false
static_rewritestring
The equivalent for static routes matched by a regex: there is no prefix to strip, so the path handed to the file lookup is built from this pattern, with {name} replaced by the named captures.
the request path
secure_pathboolean
Forwards every request on the route strictly to the exact target URL, ignoring the request's own sub-path. With target: http://localhost:8000/api/endpoint, a request for /api/endpoint/../other still reaches exactly /api/endpoint. Useful for a single-purpose endpoint that should never be traversed.
false

Rewriting with captures

A regex route can put named captures into the target path, and nowhere else: after substitution the rewritten host is compared with the configured one, and a request whose capture would have changed it is refused. A capture picks where in the path a request lands, never where it is sent.

routes.yml
- prefix: "/forum/topic"
  regex: '^/forum/topic/(?<id>[0-9]+)(?:-[a-z0-9-]+)?$'
  target: "http://127.0.0.1:9000/threads/{id}"

Keep capture classes narrow ([0-9]+, [a-z0-9-]+) rather than (?<x>.*): what a capture accepts is what ends up in the backend's path.

Two paths, one request
Request filters match on the canonicalised path, so /a/../b is evaluated as /b and cannot slip past a pattern, while forwarding uses the rules above. Route matching itself is described in Routing.
routes.yml · routeBackends

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.

FieldDescriptionDefault
proxyboolean
Enable forwarding through an intermediate proxy.
false
proxy_configstring
Address of the intermediate proxy.
http://host:port
routes.yml
routes:
  - prefix: "/partner"
    target: "https://api.partner.example"
    proxy: true
    proxy_config: "http://egress.internal:3128"

Every request on the route goes through that proxy — there is no bypass list. An https:// target is reached through a CONNECT tunnel, so the TLS session still terminates at the real backend and the egress proxy sees only the hostname.

PointBehaviorNote
always set proxy_config
The default is empty: proxy: true alone has nothing to connect to.
required in practice
combines with cert
The client certificate is presented to the backend at the far end of the tunnel, not to the egress proxy.
independent
multiple backends
A route using backends sends every attempt through the same egress proxy.
per route
identical semantics
Header handling is the same as on the direct path — including HEAD, which keeps the backend's Content-Length either way, so a route answers the same whether it goes through a proxy or not.
no surprises

Typical reasons to use it: a network where outbound traffic only leaves through a corporate proxy, or a partner who allow-lists a fixed egress address that is not the one ProxyAuth runs on.


config.jsonroutes.yml · route

Groups & roles #

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 listed in username, they belong to an allowed groups entry, or they hold an allowed roles entry. Both are checked against the matching fields on the User object, or the equivalent database columns.

roles vs. groups — a real distinction

roles predates route-level access control and originally did one thing: get forwarded to the backend as an X-User-Roles header, for the backend's own authorization logic to consume. That still happens.

It can also gate route access directly now, the same way groups does. The two coexist without conflict: whether a role matches a route's list is independent of what the backend chooses to do with the header.

groups was added specifically for access control and has no header side effect. Use it when you want a permission grouping that means nothing to the backend, 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
config.jsonCLI

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": {
  "db_type": "postgres",
  "host": "127.0.0.1",
  "port": 5432,
  "db_name": "proxyauth",
  "user": "proxyauth",
  "password": "...",
  "connect_timeout_secs": 5,
  "refresh_interval_secs": 30,
  "incremental_window_secs": 300,
  "full_refresh_interval_secs": 300,
  "deleted_retention_secs": 86400,
  "purge_interval_secs": 3600
}
FieldDescriptionDefault
db_typerequiredstring
"postgres" or "mysql".
—
host · port · db_name
user · passwordstring / integer
Connection parameters, given individually rather than as a connection URL.
—
connect_timeout_secsinteger
How long a connection attempt may take before giving up. This bounds the worst case for every blocking database call — without it, a database that accepts a TCP connection but never answers can hang for the OS default, often 30 to 130 seconds. That also determines how long a restart can appear to hang, since an attempt already in flight when SIGTERM arrives keeps running.
5
refresh_interval_secsinteger
How often the incremental scan runs. 0 disables it, leaving only the full scan.
30
incremental_window_secsinteger
The lookback window the incremental scan queries. Must be at least as long as refresh_interval_secs, ideally longer, so a change cannot fall in the gap between two scans and be missed.
300
full_refresh_interval_secsinteger
How often the full table scan runs — the only way to detect a user hard-deleted without going through the deletion log, such as a TRUNCATE TABLE users. 0 disables it, and deletions then only take effect on the next restart.
300
deleted_retention_secsinteger
How long a soft-deleted row is kept before being purged permanently.
86400 — 24 h
purge_interval_secsinteger
How often the purge of expired soft-deleted rows runs.
3600

Schema

Six tables. users carries the username, password hash, otpkey, timestamps, a soft-delete flag and must_change_password. user_allow, user_roles, groups with user_groups, and user_email carry 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

Two scans run on independent timers, at very different costs.

ScanWhat it doesInterval
incremental
An indexed query reading only users changed within the last incremental_window_secs. That window is longer than the scan interval, so consecutive scans overlap and every change is seen by at least one of them. This is what makes a freshly created or edited account usable within seconds.
refresh_interval_secs
default 30 s
full
Reads the entire table. The only way to detect a user hard-deleted from the database — an incremental scan cannot distinguish "unchanged" from "gone", since a deleted row is simply not there to compare a timestamp against.
slower, less frequent

A deleted account is only ever concluded from a genuinely fresh full-table read, never from a cache.

Local fallback cache

A local LMDB cache mirrors the last successful full read, purely as a startup and outage resilience layer. It is consulted only when the real database cannot be reached at all, never as part of the normal read path.

It is also read-only for almost everything: db-add-user and db-delete-user 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 and reset, which patches the affected user's otpkey in the cache immediately — see TOTP enrollment for why that specific case needed an exception.

Managing users

shell
$ 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 the database from the local cache
$ proxyauth db-clear-cache
config.jsonDatabase schema

Database schema #

Seven tables, one trigger, two indexes — created automatically at startup and re-checked on every start, so there is no migration step to run by hand and no SQL file to keep in sync with the binary.

The shape mirrors the file-based User object exactly. That is deliberate: an account behaves identically whether it lives in config.json or here, and neither the proxy nor the CLI has a separate code path for the two.

The tables and how they relate

%%{init: {'theme':'base','themeVariables':{ 'primaryColor':'#1a1d25','primaryBorderColor':'#e8ff47','primaryTextColor':'#ffffff', 'lineColor':'#e8ff47','textColor':'#ffffff','fontFamily':'JetBrains Mono, monospace','fontSize':'13px', 'attributeBackgroundColorOdd':'#14161a','attributeBackgroundColorEven':'#1a1d25' }}}%% erDiagram users ||--o{ user_allow : "allowed CIDRs" users ||--o{ user_roles : "roles" users ||--o{ user_email : "addresses" users ||--o{ user_groups : "memberships" groups ||--o{ user_groups : "members" users ||..o{ deleted_users_log : "on hard DELETE, via trigger" users { bigint id PK varchar username UK text password "Argon2id hash" text otpkey "TOTP secret, nullable" timestamp created_at timestamp modified_at "indexed" boolean deleted "soft delete" boolean must_change_password } user_allow { bigint id PK bigint user_id FK text cidr } user_roles { bigint id PK bigint user_id FK text role } groups { bigint id PK varchar name UK } user_groups { bigint id PK bigint user_id FK bigint group_id FK } user_email { bigint id PK bigint user_id FK text email boolean is_primary } deleted_users_log { bigint id PK varchar username timestamp deleted_at "indexed" }
Every child table cascades on delete. deleted_users_log deliberately does not — it has to outlive the row it records.

users

The only table the login path reads directly. Everything else hangs off it.

ColumnMeaningType
idPK
Surrogate key. This is what child rows reference, so a username can be changed without touching anything else.
BIGSERIAL · BIGINT AUTO_INCREMENT
usernameunique
The login name. Uniqueness is enforced by the database, not only by the CLI.
VARCHAR(255)
password
Argon2id hash. A plaintext value never reaches this column — db-add-user hashes before writing.
TEXT
otpkey
Base32 TOTP secret, or NULL for an account that has never enrolled. Written by /adm/auth/totp/get, cleared by the reset endpoint.
TEXT, nullable
created_at
Set once, on insert.
TIMESTAMP
modified_atindexed
The column the incremental scan queries — WHERE modified_at >= cutoff. On MySQL it carries ON UPDATE CURRENT_TIMESTAMP, so the database maintains it by itself; on PostgreSQL the application sets it on every write.
TIMESTAMP
deleted
The soft-delete flag. db-delete-user sets it rather than removing the row, which is what lets every instance's incremental scan notice the deletion within seconds. load_users filters on deleted = FALSE.
BOOLEAN
must_change_password
The next successful login issues a reset link instead of a session. Cleared the moment a new password is set. See Password reset.
BOOLEAN

The child tables

TableHoldsMaps to
user_allow
One row per CIDR or address an account may log in from. Empty means any address.
allow
user_roles
One row per role. Free text here — a role is not a parent entity, since it is also forwarded verbatim as X-User-Roles for the backend to interpret.
roles
groups + user_groups
A real parent table plus a join table, with UNIQUE (user_id, group_id). See below for why groups get this treatment and roles do not.
groups
user_email
One row per address, with an is_primary flag. The primary one is where a reset link is sent.
email
deleted_users_log
Not part of the User object at all — a record of hard deletions, populated by a trigger. See below.
—
Why groups is a table and roles is a column
A group name repeated as free text on every membership row has two failure modes: a typo silently creates a phantom group no route will ever match, and renaming a group means updating every member. A parent table with a foreign key makes the first impossible and the second a one-row UPDATE.

Roles are kept as plain text on purpose — they cross the boundary into the backend as a header, so an unknown role is a legitimate value rather than a mistake. The table is named groups and the column name rather than group, which is a reserved word in both engines.

Two kinds of deletion

The distinction is what makes a deletion propagate quickly instead of waiting for the next full scan.

HowWhat happensNoticed by
db-delete-usersoft
Sets deleted = TRUE and touches modified_at. The row stays until deleted_retention_secs elapses, then the purge removes it for good.
the incremental scan, within seconds
DELETE FROM usershard
Someone going straight to SQL, bypassing the application. The trg_log_user_delete trigger writes the username into deleted_users_log as the row goes, so the deletion is still visible to a time-windowed query.
the log, then the full scan as a backstop
Why the log exists at all
An incremental scan compares timestamps, and a row that no longer exists has no timestamp to compare. Without the trigger, a raw DELETE would only be noticed by the next full_refresh_interval_secs pass — the deleted account keeping access in the meantime. The log turns "a row vanished" into a row that can be read like any other.

Created for you, on every startup

Schema creation runs before the first scan, and is written to be safe to re-run: CREATE TABLE IF NOT EXISTS everywhere, plus explicit ALTER TABLE statements for the four columns added after the first release.

EngineHow migration is handledNote
postgres
ADD COLUMN IF NOT EXISTS, supported natively, so the statement is simply re-run every start.
9.6+
mysql
That clause is not reliable across every targeted version, so the ALTER is attempted unconditionally and a "duplicate column" error is read as "already migrated".
MySQL · MariaDB

The trigger is dropped and recreated on each start, since neither engine offers a dependable CREATE TRIGGER IF NOT EXISTS. If a second instance recreates it in between, an "already exists" error is treated as success — the trigger being present is the only thing that matters.

The account needs DDL rights
Creating tables, indexes, a function and a trigger all happen under the databases.user account. A connection restricted to SELECT, INSERT, UPDATE and DELETE will fail at startup with the failing statement named. Grant DDL for the first start, or create the schema by hand from the DDL below and keep the runtime account narrow.

The schema, written out

What ProxyAuth issues. Useful for review, for a managed database where the application account has no DDL rights, or simply to know what will appear.

PostgreSQL
CREATE TABLE IF NOT EXISTS users (
    id                   BIGSERIAL PRIMARY KEY,
    username             VARCHAR(255) NOT NULL UNIQUE,
    password             TEXT NOT NULL,
    otpkey               TEXT,
    created_at           TIMESTAMP NOT NULL DEFAULT now(),
    modified_at          TIMESTAMP NOT NULL DEFAULT now(),
    deleted              BOOLEAN NOT NULL DEFAULT FALSE,
    must_change_password BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_users_modified_at ON users (modified_at);

CREATE TABLE IF NOT EXISTS user_allow (
    id      BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    cidr    TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS user_roles (
    id      BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    role    TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS groups (
    id   BIGSERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL UNIQUE
);

CREATE TABLE IF NOT EXISTS user_groups (
    id       BIGSERIAL PRIMARY KEY,
    user_id  BIGINT NOT NULL REFERENCES users(id)  ON DELETE CASCADE,
    group_id BIGINT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
    UNIQUE (user_id, group_id)
);

CREATE TABLE IF NOT EXISTS user_email (
    id         BIGSERIAL PRIMARY KEY,
    user_id    BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    email      TEXT NOT NULL,
    is_primary BOOLEAN NOT NULL DEFAULT FALSE
);

CREATE TABLE IF NOT EXISTS deleted_users_log (
    id         BIGSERIAL PRIMARY KEY,
    username   VARCHAR(255) NOT NULL,
    deleted_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_deleted_users_log_deleted_at
    ON deleted_users_log (deleted_at);

CREATE OR REPLACE FUNCTION log_deleted_user() RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO deleted_users_log (username) VALUES (OLD.username);
    RETURN OLD;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_log_user_delete ON users;
CREATE TRIGGER trg_log_user_delete
    AFTER DELETE ON users
    FOR EACH ROW EXECUTE FUNCTION log_deleted_user();
MySQL · MariaDB
CREATE TABLE IF NOT EXISTS users (
    id                   BIGINT AUTO_INCREMENT PRIMARY KEY,
    username             VARCHAR(255) NOT NULL UNIQUE,
    password             TEXT NOT NULL,
    otpkey               TEXT,
    created_at           TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    modified_at          TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
                                   ON UPDATE CURRENT_TIMESTAMP,
    deleted              BOOLEAN NOT NULL DEFAULT FALSE,
    must_change_password BOOLEAN NOT NULL DEFAULT FALSE,
    INDEX idx_users_modified_at (modified_at)
);

-- user_allow, user_roles, groups, user_groups and user_email are
-- identical to the PostgreSQL definitions above, with BIGSERIAL
-- replaced by BIGINT AUTO_INCREMENT.

CREATE TABLE IF NOT EXISTS deleted_users_log (
    id         BIGINT AUTO_INCREMENT PRIMARY KEY,
    username   VARCHAR(255) NOT NULL,
    deleted_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_deleted_users_log_deleted_at (deleted_at)
);

DROP TRIGGER IF EXISTS trg_log_user_delete;
CREATE TRIGGER trg_log_user_delete
    AFTER DELETE ON users
    FOR EACH ROW
    INSERT INTO deleted_users_log (username) VALUES (OLD.username);

How a read actually happens

Five separate queries, joined in memory rather than in SQL. string_agg and GROUP_CONCAT would each need their own dialect, and the row counts here are small enough that assembling the objects in the application costs less than maintaining two versions of one query.

what load_users issues
SELECT id, username, password, otpkey, must_change_password
  FROM users WHERE deleted = FALSE;

SELECT user_id, cidr AS value FROM user_allow;
SELECT user_id, role AS value FROM user_roles;

SELECT user_groups.user_id, groups.name AS value
  FROM user_groups JOIN groups ON groups.id = user_groups.group_id;

SELECT user_id, email AS address, is_primary FROM user_email;
Editing rows by hand
Writing directly to these tables works — the incremental scan picks the change up like any other. Two things to respect: hash the password with Argon2id before inserting it, since nothing re-hashes a value that arrives already in the column, and touch modified_at on PostgreSQL, where the database will not do it for you. db-add-user handles both, which is why it is the recommended path.
HTTP APIUsers & identity

TOTP enrollment #

Enrollment and reset work identically whether an account lives in config.json or in a shared database — the same two routes detect automatically which one a given user belongs to.

Two storage backends, two ways of staying in sync

Both backends work around the same constraint: the running server holds its own in-memory view of accounts, and a write to storage does not update that view on its own. Each keeps the two in step differently, and in both cases the change is live without a restart.

BackendHow the in-memory view is kept currentRestart needed
file-based
AppState.config is loaded once 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.
no
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 meant to be mutable in the first place.
no
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

Why the fallback cache is patched too

Database-backed accounts have a third layer behind the two above: the local LMDB cache that stands in for the real database if it is ever unreachable, most importantly right at startup. That cache is only a resilience fallback, not part of the normal read path.

An enrollment or reset patches it immediately anyway, alongside the real database write — specifically so a database outage occurring shortly afterward cannot make ProxyAuth fall back to a stale, since-replaced secret.

POST/adm/auth/totp/get
AuthUsername and password in the body — not the admin token
Body{"username": "alice", "password": "..."}
BehaviorFirst-time enrollment for either account type. Returns 409 if a secret already exists — see self-service re-enrollment for the opt-out.
POST/adm/auth/totp/reset
AuthX-Auth-Token: <token_admin>
Body{"username": "alice"}
BehaviorClears the secret wherever the account actually lives. The old secret stops working immediately.
routes.yml · vhostUsers & identity

Self-service re-enrollment #

Understand the trade-off first
This is a narrow, deliberate exception to how TOTP re-enrollment works, scoped to one vhost. The section below states exactly what it changes so the decision is made with the consequence in view.

/adm/auth/totp/get normally refuses to hand out a new secret to an account that already has one, even with the correct password. Replacing an enrolled second factor therefore requires an administrator.

allow_totp_reenroll: true on a vhost removes that restriction: a user with a working username and password gets a brand-new TOTP secret in one request, with no admin and no prior reset call.

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

What changes on that vhost

With this on, the password becomes sufficient to replace the TOTP secret: a holder of valid credentials can enroll a new authenticator in one request, and the previous one stops working. The second factor therefore protects against a device lost or an authenticator app reinstalled, rather than against a password that has been obtained by someone else.

It is scoped to a single vhost, so an instance can offer self-service re-enrollment on an internal tool while keeping admin-mediated resets everywhere else. Where the second factor is meant to hold independently of the password, leave it off and use proxyauth reset-otp.

CLIUsers & identity

Password reset #

Two related but distinct flows, both landing on the same page_change_password page with a single-use token.

Admin-initiated reset

shell
$ 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 does not lock her out immediately, it gives her a way back in without needing the old password.

Both smtp and page_change_password can be set per vhost. Name --vhost explicitly to use that vhost's own values; this command has no live request to resolve one from automatically. Omit it to use the global default.

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

Note
Every prerequisite for the admin-initiated flow — smtp configured, page_change_password configured, the user has an email on file — is checked up front, and every problem found is reported together in one run rather than one at a time across repeated attempts.
config.jsonUsers & identity

Email templates #

Every email ProxyAuth sends is a plain-text file on disk under /etc/proxyauth/mail/templates/, with {{ }} placeholders substituted at send time. One file per purpose. Today that is password reset, the only flow that sends mail.

The file

AspectDetailValue
path
Fixed, not configurable. The directory is organized per purpose so a second email flow does not have to compete for the same flat folder.
/etc/proxyauth/mail/templates/reset_password.txt
created by
proxyauth prepare, with the default content below — only if the file does not already exist. A template you have edited is never overwritten, including by a later prepare or an upgrade.
once
read
Once, when the SMTP client is built. Editing the file takes effect on the next run of the command that sends the mail, not mid-process.
at client construction
format
text/plain. There is no HTML variant — the body is sent exactly as written.
plain text

Structure

The first line must be the subject, in this exact shape, and it is stripped from the body before sending:

/etc/proxyauth/mail/templates/reset_password.txt
Subject = "ProxyAuth Password Reset"
Hello {{ username }},

A password reset request has been initiated for your account.

Click the link below to set a new password:
{{ reset_link }}

This link is valid for a limited time.

If you did not request this, simply ignore this message — your
current password will keep working.

—
ProxyAuth Security System
PlaceholderSubstituted withNote
{{ username }}
The account the reset was requested for.
spaced form only
{{ reset_link }}
page_change_password with token=<token> appended — joined with ?, or with & if that URL already carries a query string.
spaced form only
Two rules the parser enforces
The first line must start with Subject = and the subject itself must be in double quotes — anything else fails with the reason named, and no mail is sent. And unlike the dynamic tags in HTML pages, these placeholders are matched only in their spaced form: {{ username }} is substituted, {{username}} is left in the message as literal text.

Transport

Built from the smtp block, resolved per vhost when --vhost names one. The port decides the encryption, with no separate setting:

PortModeNote
465
Implicit TLS — the connection is encrypted from the start.
SMTPS
anything else
STARTTLS, upgrading the connection after the greeting. This is the path 587 takes.
submission

Credentials are always sent — username and password are not optional — and timeout_secs bounds the whole exchange.

PropertyDetailValue
lifetime
Identical for both flows — an admin-initiated reset-password and the automatic one issued on a must_change_password login.
3600 — one hour
single use
Validated and deleted in one LMDB read-write transaction, so two simultaneous submissions cannot both succeed.
atomic
storage
Its own LMDB environment at /opt/proxyauth/db/password_reset, deliberately separate from the revocation and user caches. The directory is forced to 0700 rather than trusting the process umask.
owner-only
purge
An hourly sweep deletes expired entries. An expired token is refused on lookup regardless, so the sweep is housekeeping rather than enforcement.
hourly
Which address receives it
The account's primary email entry — the one flagged by --primary-email. An account with no address on file is reported as a missing prerequisite before anything is sent, alongside any other problem found in the same run.
Rewriting it
Everything below the first line is yours: wording, signature, language. Keep {{ reset_link }} somewhere in it — without it the recipient has no way to reach the reset page — and keep the subject line in the exact Subject = "…" form. Send yourself a test with proxyauth reset-password --username <you> after editing.

config.jsonOperations

Logging & observability #

Two settings that look similar and are not. log chooses the transport — where every line the process emits ends up. logging shapes the access line — what a per-request record contains and which requests get one.

The practical consequence of keeping them apart: logging.enabled: false drops access lines while leaving warnings and errors intact, which is what "turn off logging on this endpoint" usually means in practice. log.type: "disabled" silences everything.

Two files, on purpose

FileContainsWritten by
/var/log/proxyauth/proxyauth.logDiagnostics — warnings, errors, startup messages.the local transport
/var/log/proxyauth/access.logOne line per request.the access-log writer, directly

The local transport explicitly filters the access-log target out. Without that filter every request would land in both files, since access lines are emitted through the same tracing call as everything else. Keeping "requests" and "everything else" separate is the intent.

Transports — the log block

typeBehaviorRequires
localdefault
Writes to /var/log/proxyauth/proxyauth.log directly rather than to stdout, so the destination is the same whether the process is started by systemd, by an init script, or by hand.
—
loki
Streams to Grafana Loki over HTTP, and keeps a local formatted layer alongside it. See below.
host
http
Buffers lines through an in-process channel to a collector, for a generic HTTP sink.
write_max_logs
disabled
No subscriber at all. Nothing is written anywhere — access lines included.
—
Set this explicitly
The example config.json ships with {"type": "disabled"} so a fresh install writes nothing until you choose a destination. Set log.type to local, loki or http to start recording.

Under the http transport, write_max_logs is required and must be below 100 000. It is validated at startup, so a missing or out-of-range value is reported immediately rather than at the first log line.

Grafana Loki

One line to enable, no agent to install and no file to tail — ProxyAuth pushes to Loki itself.

config.json
"log": {
  "type": "loki",
  "host": "http://loki.internal:3100"
}
What is sentDetailNote
applabel
Always proxyauth. This is the label you select on: {app="proxyauth"}.
fixed
pidfield
The emitting process ID. This is what lets you tell instances apart when num_instances is above 1 — they all carry the same label otherwise.
per process
Levelfilter
Only INFO and above reaches Loki. Trace and debug stay local.
INFO+
Targetfilter
Only events from ProxyAuth's own modules. Noise from the underlying HTTP server is not shipped.
proxyauth*
Note
The URL must parse as a valid URL and the host key must be present — both are checked at startup and both are fatal. Point it at Loki's own address, not at Grafana: Grafana reads from Loki, it does not receive pushes.

The access line — the logging block

config.json
"logging": {
  "enabled": true,
  "format": "[time] [[vhost]] [[ip]] - [method] [status] [path] [request-time]",
  "log_file": "access.log",
  "flush_interval_ms": 500,
  "vhosts": {
    "noisy.example.com": { "enabled": false },
    "portal.example.com": { "log_file": "portal.log" }
  },
  "routes": {
    "/healthz": false
  }
}
FieldDescriptionDefault
enabledboolean
Proxy-wide switch for the access line. On by default, so access lines are produced from the first start and an upgrade to a build carrying this block keeps producing them. Also accepted as log.
true
formatstring
Placeholder tokens mixed with literal text, compiled once at startup. An unknown placeholder is emitted verbatim, so the format string and the resulting line always stay in correspondence. Also accepted as format-log or format_log.
see below
log_filestring
Global access log, always under /var/log/proxyauth/. Give a filename, not a path — absolute paths and ../ are rejected at startup.
access.log
vhostsmap
Per-hostname overrides, keyed lowercase with the port stripped. Each entry takes enabled and log_file. This applies to every request arriving on that Host, including ones matching no route at all, so one domain's log volume can be turned down independently of the others.
{}
routesmap
Per-prefix on/off, as an alternative to setting log: on the route itself — useful for keeping every logging decision in one file. The route's own log: wins when both are set.
{}
flush_interval_msinteger
How often buffered writers flush to disk. Lines sit in memory between flushes rather than costing a syscall each — this is the real trade-off knob. Lower means lines appear sooner while you are tailing a file; higher means fewer syscalls under load. Below roughly 50 ms you are approaching per-line flushing and mostly defeating the buffering.
500
resource_sample_interval_secsinteger
How often [cpu-usage] and [memory-usage] are re-sampled from /proc/self. No sampler is started at all unless the format actually uses one of them. Sampling rather than reading per request keeps a syscall and a file parse off the critical path, at the cost of a value up to this many seconds stale.
1

Format placeholders

The default:

default format
[time] [[vhost]] [[ip]] - [method] [protocol] [status] [length] [path] [tid:[token-id]] '[user-agent]' '[referer]' [request-time-ns]
GroupPlaceholders
request[method] [path] [query] [protocol] [host] [route]
client[ip] [x-forwarded-for] [user-agent] [referer]
identity[username] [token-id] [vhost]
response[status] [length] [request-time] [error_detail]
process[time] [cpu-usage] [memory-usage]

Every hyphenated name also accepts an underscore — [user_agent] works exactly like [user-agent] — and five carry a shorter alias, for formats inherited from another proxy:

CanonicalAlso accepted
[username][user]
[token-id][tid]
[time][timestamp]
[referer][referrer] — the HTTP header is famously misspelled, so both read the same field
[error_detail][error-detail], [error]
A name it does not know
An unrecognized [name] is emitted verbatim rather than dropped. A typo shows up as [stauts] in the log line, which is far easier to spot than a column that silently vanished — and it means a format containing literal square brackets still round-trips.
Tip
Shipping to Loki and want structured queries rather than regex parsing? Write the format as key=value pairs — method=[method] status=[status] path=[path] user=[username] ms=[request-time] — and Loki's logfmt parser picks the fields up directly.

Per-route overrides

FieldDescriptionDefault
logboolean · route
Access logging for this route. Resolution order is route log → logging.routes[prefix] → logging.enabled; only an unset route falls through to the next level.
inherit
log_filestring · route
Writes this route's access lines to /var/log/proxyauth/<log_file> in addition to the global access log, not instead of it. Absolute paths and path traversal are rejected at startup.
inherit
CLIOperations

Stats socket #

proxyauth stats reads live statistics directly from the running instance over a local Unix domain socket — no HTTPS round-trip, and no admin token needed for this local-only path.

shell
$ 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
FieldMeaningType
requests_per_second
The last completed one-second sample, not a rolling figure.
integer
avg_rps_10s · avg_rps_60s
Rolling averages over the last 10 and 60 samples. Both read 0 until enough samples exist.
float
total_requests
Since this process started. Counters are padded to avoid false sharing, so requests on different workers almost never contend on the same cache line.
integer
uptime_seconds
Process uptime.
integer
active_sessions
Distinct tokens currently counted as active.
integer
Scope of these figures
Counters are per process, and every num_instances server lives in one process — so these figures already cover the whole instance, whatever that setting is. GET /adm/stats returns the same structure over HTTP for a monitoring system to scrape.
Note
The socket lives at /opt/proxyauth/run/stats.sock, created by the server at startup. Its filesystem permissions — owner-only, inside a directory that is itself owner-only — are the authentication, doing the same job token_admin does for the equivalent GET /adm/stats endpoint, which remains available for monitoring systems not running on the same host.

Why a socket rather than an endpoint

The socket lives at /opt/proxyauth/run/stats.sock, owner-only inside an owner-only directory. Those permissions are the authentication: whoever can read the file is already run_user or root on the machine. No admin token in a shell history, no TLS round-trip, nothing listening on the network.

The same figures from elsewhere

EndpointReturnsAuth
GET /adm/stats
The same structure as proxyauth stats, as JSON, for a monitoring system on another host.
token_admin
GET /adm/stats/sessions
Per-token usage counters — the token ids to hand to /adm/revoke when a session has to go.
token_admin

Both are outside the rate limits, so a monitoring system is never throttled during an incident, and both require "stats": true like the socket itself.

Counters are per process, which is the whole host
num_instances starts several servers inside one process, sharing these counters, so the figures already cover the instance rather than one worker. Counters are padded to sit on their own cache lines: without that, workers incrementing neighbouring values in the same line would slow each other down for no reason.
config.jsonroutes.ymlOperations

Startup checks #

Both configuration files accept keys they do not know, so an older file keeps loading on a newer binary. The flip side is that a typo produces a setting that silently does nothing. At startup ProxyAuth reports such keys, with the setting most likely meant, and prints the session policy it will actually apply.

startup output
warning: config.json: unknown key `refresh_token` is ignored — did you mean `session_refresh_interval_secs`?
warning: routes.yml › vhosts[0] (blog.example.com) › routes[2] (/admin): unknown key `requierd_login` is ignored — did you mean `required_login`?
session renewal every 900s, previous token accepted 30s more, sessions end 43200s after login
session renewal on api.example.com/v1: off (session_refresh_interval_secs = 0)

Lines go to standard error and to the application log. They are warnings: startup continues.

Unknown keys

CheckedDetailLocation shown
config.json
Top-level keys, compared with every setting ProxyAuth writes back, plus the acme alias.
config.json
routes.yml
The top level, every vhosts: group and every route, compared with the route fields and their aliases (static, csrf_token, session_cookie_age, login_page_url, certbot_rew).
group index and hostname, route index and prefix

Suggestions come from the words people naturally use first — refresh, renew, grace, cookie_ttl, login_page — then from spelling distance.

Session policy summary

One line for the global policy, one for each vhost or route that overrides it, and a warning when renewal is configured but can never happen:

startup output
warning: session renewal on /admin never happens: session_refresh_interval_secs (3600) is not shorter than token_expiry_seconds (3600)

If you set session_refresh_interval_secs and still read session renewal off, the value is not reaching the running binary: check the file that was actually loaded, the spelling, and that the service runs a 1.3.0 build.

config.json is rewritten losslessly

ProxyAuth rewrites config.json at startup when it has something to persist: a plaintext password replaced by its Argon2id hash, users sorted, a generated token_admin. Every setting is written back. Earlier versions left out several — among them smtp, trust_proxy_forward_for, ip_blocklists, run_user, run_group, max_body_size, backend_timeout, strict_mtls and page_change_password — which were therefore deleted from the file at that point.

Keys the file does not know are not preserved
A rewrite writes the settings ProxyAuth understands. A misspelt or retired key is dropped from the file on the next rewrite — one more reason to act on the warnings above.
CLIOperations

SSO across instances #

Since token construction depends on per-build constants and not just secret, two independently built instances do not validate each other's tokens by default — even with identical configuration. sync export and sync import are how a fleet is made to agree on the same constants.

Export from the source instance

Generates a fresh OpenPGP certificate via Sequoia, encrypts its own private key material with a passphrase derived from that instance's secret, and writes it to key.asc. It then encrypts its current build constants — version, build time, build random value, two build seeds, a build epoch, the 32-byte HKDF salt and the shuffle order — into data.gpg, addressed to that same certificate.

shell — source instance
$ proxyauth sync export
Key export Success (secret key encrypted with AppConfig password)
# written to the current working directory
Transfer both files out of band

scp, a shared volume, however you would move any other secret. Place them in /etc/proxyauth/import/ on the target.

Import on the target instance

The target uses its own — matching — secret to unlock key.asc's private key, then decrypts data.gpg with it. The decrypted constants overwrite the target's own in a runtime-mutable global, despite originating from a compile-time one. From that point on it builds and verifies tokens exactly as the source instance would.

shell — target instance
$ proxyauth sync
Version-checked
An import is validated before anything is adopted: a keystore carrying a build secret shorter than the current 32 bytes is refused with the reason named, so an importing instance always ends up with key derivation at full strength. Upgrade the source instance and re-run sync export to produce a current pair.
Prerequisite
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 covers the other half — the per-build constants — which a matching secret alone does not carry.
CLIOperations

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

CommandDescriptionNotes
prepare[--insecure]
First-time setup. Creates the proxyauth user and group, and the config and certificate directories with correct ownership and permissions.
--insecure relaxes permissions — local testing only

Guided setup

CommandDescriptionNotes
quickstart vhost
Six questions, one new vhosts: entry: hostnames, TLS, browsers or API, who may sign in, the application, the login page. See Quick start.
routes.yml · live
quickstart users
One account, in the database or in config.json, with an Argon2id hash, groups, roles, email and an optional temporary password.
database: live · config.json: restart
quickstart vhost-access
Sets allow_users, allow_groups, allow_roles and exclude_users on a vhost, replacing what was there.
routes.yml · live

All three show the block before writing it (default answer: no), back the file up as <file>.bak-<timestamp>, validate routes.yml with the server's own parser before committing, and print the block again afterwards.

Database-backed users

CommandDescriptionNotes
db-add-user--username U [--password P] [--email E…] [--primary-email E] [--must-change-password]
Creates or updates a user directly in the database. Omitting --password prompts interactively with hidden input.
--email is authoritative, not a merge — omitting it clears any address on file
db-delete-user--username U
Soft-deletes: the row is kept and marked deleted, so every connected instance's incremental scan picks it up and revokes access right away. Purged permanently later on its own, after databases.deleted_retention_secs — 24 hours by default.
—
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 forced
db-restore-from-cache[--force]
Re-populates the database from this instance's local cache — the last known-good snapshot, written there on every successful full read. For restoring a database that has come back empty. Never runs automatically.
refuses if the database already has users unless forced
db-clear-cache
Wipes the local LMDB fallback cache. The next successful full read repopulates it.
—

Accounts & access

CommandDescriptionNotes
reset-password--username U [--vhost V]
Emails a single-use password reset link. See Password reset.
name --vhost to use that vhost's own SMTP and reset page
reset-otp--username U
Clears a user's TOTP secret. See TOTP enrollment.
—
users[--list]
Every known account, file and database alike, its groups and roles, and every route it can currently reach along with the mechanism granting it.
—
groups[--list]
Every group currently referenced by an account or a route — current members, and which routes list it directly.
—
roles[--list]
The same, for roles.
—
routes-audit
What secures each route — a username, a group, a role, or public — read 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, with ✓ / ✗ and exactly why.
—
check-routes
Every route resolved down to the concrete accounts that currently satisfy it.
flags a route whose group or role matches no current account

TLS & certificates

CommandDescriptionNotes
certbot renew<vhost|all> [--force]
Renews now, via the same native ACME mechanism the periodic scan uses. A named vhost only needs vhost_cert configured — running the command by hand is itself sufficient intent, certbot_renew is not required. all is stricter: it only touches vhosts that did opt into automatic renewal. Without --force, a certificate that is not yet due is left alone and the command exits without contacting Let's Encrypt at all.

Naming one member of a multi-name group renews the whole group onto one certificate. A group containing a wildcard prompts for manual DNS-01 and therefore needs a terminal.
—
certbot check<vhost|all>
Certificate status and days remaining.
—
certbot new<vhost>
Issues a certificate for a vhost that does not have one yet — unconditionally, no --force, since there is nothing to compare against a threshold. Does not accept all. vhost_cert must already be set for it. Restart afterwards if the vhost was only just added.

Covers every name sharing that vhost_cert, and switches to manual DNS-01 if any of them is a wildcard.
—

Operations

CommandDescriptionNotes
stats
Live statistics over the local Unix socket. See Stats socket.
no admin token needed
sync[target]
Imports build constants from /etc/proxyauth/import/. See SSO across instances.
—
sync export
Produces the keystore pair for another instance to import, written to the current working directory. Must run as root.
—
HTTP APISessions

Sessions with ProxyAuth #

ProxyAuth's own login system, served on every vhost that does not delegate identity to its backend. Two endpoints, /auth and /logout, and one setting — session_cookie — that decides which of two very different shapes they take.

This is the opposite direction from the OIDC provider. Here ProxyAuth authenticates the visitor and tells the backend who they are; there, the backend authenticates through ProxyAuth and manages its own sessions. A vhost does one or the other, never both.

One setting, two audiences

A browser and a script want incompatible things from a login. session_cookie is how you say which one this vhost serves, and every other difference follows from it.

Aspectsession_cookie: true — dashboards, interfacessession_cookie: false — APIs, scripts
Who calls it
A browser submitting an HTML form, which stores cookies and follows redirects on its own.
curl, a CI runner, a mobile app — no cookie jar, no redirect following
/auth answers
303 See Other to login_redirect_url, with a session_token cookie attached.
200 with {"token", "expires_at"}
Carrying it afterwards
The browser resends the cookie by itself. Nothing to write.
Authorization: Bearer <token> on each request
Applies. A cookie is sent by the browser on any request, including one a third-party page triggered, so the form needs {{ csrf_token }}.
Does not apply — set csrf_token: false. A bearer header is never attached automatically, so there is nothing to forge.
Lifetime
The cookie gets max_age_session_cookie, capped at token_expiry_seconds — the cookie can never outlive the token it carries.
token_expiry_seconds, returned as expires_at; with refresh_token_expiry_seconds, a refresh token to renew it
Why the two shapes exist at all
Only a browser can be sent a redirect and be expected to follow it. A fetch or a curl call would either not follow the 303 or follow it and discard what mattered. An API client needs the same information back as data it can act on, which is why the JSON form is not simply the redirect with a body bolted on.

The browser flow

sequenceDiagram participant B as Browser participant PA as ProxyAuth participant APP as Backend B->>PA: GET /app — no session PA->>B: redirect to the login page B->>PA: GET / — the login page Note over PA: tag_proxyauth fills the {{ csrf_token }}
into the form before serving it B->>PA: POST /auth — username, password, csrf_token Note over PA: credentials verified, token signed PA->>B: 303 to login_redirect_url
+ session_token cookie B->>PA: GET /app — cookie sent automatically PA->>APP: the request
+ X-User, X-User-Roles, X-User-Groups APP->>PA: the page PA->>B: the page
The backend never sees the password, and never sees the token either.

The API flow

shell
$ TOKEN=$(curl -s -X POST https://api.example.com/auth \
      -d 'username=ci-runner&password=...' | jq -r .token)

$ curl https://api.example.com/v1/builds -H "Authorization: Bearer $TOKEN"

No login page, no cookie, no CSRF. The token is obtained once and presented on each call until expires_at passes.

What the backend receives

In both modes the backend is handed an identity, never a credential. The token stays between the client and ProxyAuth.

headers added to the proxied request
X-User: alice
X-User-Roles: admin,ops
X-User-Groups: engineering

A backend behind a required_login route can therefore trust these headers without implementing any authentication of its own — which is the entire point of gating an app that has no login. Add forward_proxy_headers when it also needs the visitor's real address and the original Host.

Client-supplied copies are stripped
A request arriving with its own X-User header never reaches the backend with it. These are ProxyAuth-asserted values, and a caller cannot pre-set them to impersonate someone.

POST /auth

POST/auth
Bodyusername, password, plus csrf_token when CSRF is on for the vhost and a TOTP code when login_via_otp is.
Who mayOnly accounts the vhost names — see Login authorization. A vhost naming nobody accepts no login at all.
LimitsRate-limited by ratelimit_auth, independently of proxied traffic. OPTIONS is answered for the CORS preflight against cors_origins.
Forced changeAn account carrying must_change_password gets the reset page instead of a session — as a redirect in cookie mode, as {"must_change_password", "reset_link"} in JSON mode. See Password reset.

GET /logout

GET/logout
AuthThe session_token cookie. Nothing else is needed, and a missing or already-expired cookie is not an error.
BehaviorRevokes the token server-side — the record is kept until the token's own expiry, then purged — and, for a token issued by a renewal, refuses the token it replaced at once. Then clears the cookie by resending it expired at the Unix epoch, then redirects to logout_redirect_url — the vhost's own value if it sets one, otherwise the global. With no redirect configured, answers 200.
Bearer clientsThere is no cookie to clear, so calling it is optional. A client that wants a token dead before its expiry uses /adm/revoke with the token ID.
Logging out revokes, it does not only forget
The token is added to the same revocation set /adm/revoke writes to, and every subsequent verification checks it — so a copy of that token taken before logout stops working immediately.

Clearing a cookie only asks the browser to forget. A token captured beforehand — through XSS, a shared machine, an exposed log — would otherwise keep working until its natural expiry, and the person who clicked "sign out" would have no way to know. Revocation is best-effort: an unreadable or already-expired cookie leaves nothing to revoke, and the cookie is cleared regardless.
Across instances
Revocation is local to the node unless redis is configured. Without it, logging out on one instance leaves the token usable on the others until it expires.

Keeping a session alive

With session_refresh_interval_secs set, a browser session in use receives a fresh token at that interval, in a new Set-Cookie, on protected and public pages alike. The visitor never signs in again until the session is left idle past token_expiry_seconds or reaches session_max_lifetime_secs.

Requests without a valid session

A page opened on a required_login route without a valid session is sent to the login page with a 302; an API call gets 401; a signed-in account that is not allowed on the route gets 403. The full rules are in Login redirect.

On an OIDC vhost, neither applies

Where a vhost sets oidc:, both endpoints proxy straight through to the backend like any other path, and ProxyAuth's own session machinery steps aside entirely. A relying party such as Grafana has its own login and logout UI, and it is that UI visitors reach.

PathOrdinary vhostOIDC vhost
/auth
ProxyAuth verifies the credentials and issues a session.
proxied
/logout
Revokes and clears the session.
proxied
login happens at
the login page, posting to /auth
logout happens at
/logout
HTTP APIOperations

Admin API reference #

Every /adm/* route requires X-Auth-Token: <token_admin>, with the two TOTP endpoints as documented exceptions.

Treat token_admin as a root credential
token_admin is the sole gate on these endpoints, and they are exempt from rate limiting so administration and monitoring keep working under load. Scope access accordingly — an allow_ips route, a private interface in host, or a firewall rule in front.
GET/adm/stats
Requiresstats: true in config.json
ReturnsRequests per second (last, 10s average, 60s average), total requests, active sessions, uptime — the same data proxyauth stats reads locally over the Unix socket.
GET/adm/stats/sessions
ReturnsPer-token usage counters — which token IDs have been used, how many times, and by whom.
POST/reset-password
AuthThe single-use token from the reset link — not the admin token. Public by necessity.
Bodytoken, password, verif_password
BehaviorWhere the page_change_password form submits. ProxyAuth appends ?token=… to that URL itself; the page there posts back here. See Password reset.
GET/adm/logs
ReturnsRecent access-log entries, filterable. See the wiki for the query parameter reference.
POST/adm/revoke
Body{"token_id": "..."} — an exp field is still accepted, and ignored
BehaviorAdds the token id to the revocation set checked on every subsequent token verification: the token stops working immediately. The record is kept for the longest lifetime any token can have — token_expiry_seconds, or the largest refresh_token_expiry_seconds — and then purged from memory, LMDB and Redis. With refresh tokens, revoking the token id ends the access token and the refresh token together. Synced across instances via Redis when configured. See Revocation.
Returns200 Token revoked with expiration. · 400 for an empty id or an internal rotated: key · 401 without a valid X-Auth-Token
POST/adm/auth/totp/get
AuthUsername and password in the body, not the admin token
BehaviorFirst-time TOTP enrollment. See TOTP enrollment.
POST/adm/auth/totp/reset
Body{"username": "alice"}
BehaviorClears a user's TOTP secret so they can re-enroll. See TOTP enrollment.