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.
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.
$ 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
1 · detect/etc/os-release markers. Debian and Ubuntu, Alpine, Arch, and Fedora and its relatives are recognised.2 · dependenciespkg-config, OpenSSL headers, and the PostgreSQL and MariaDB client libraries. Nothing is fetched from outside your distribution's repositories at this step.3 · Rustcargo 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.4 · system userproxyauth system account and group: no home directory, no login shell (nologin), no password. An account of that name that already exists is left untouched.5 · downloadcrates.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.6 · checksum7 · extract/tmp/proxyauth_install and locates the Cargo.toml.8 · compilecargo 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.9 · directoriesproxyauth with mode 750. See the table below for the full list.10 · certificatelocalhost so the instance can start over TLS immediately. An existing pair at those paths is kept and only has its ownership corrected.11 · prepareproxyauth prepare, the binary's own setup command, which finishes the directory layout and permissions.12 · servicecrates.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.
| Path | Contents | Owner · mode |
|---|---|---|
| /usr/local/bin/proxyauth | The 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.service | The service unit, or /etc/init.d/proxyauth under OpenRC. | root |
| /tmp/proxyauth-install.log | The 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.
User · GroupCapabilityBoundingSetsetcap, so it works the same when started by hand.NoNewPrivilegesRestartBefore you go to production
The install leaves a working instance; three things are then yours to set.
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.
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.
The shipped example sets log.type to disabled. Set it to local, loki or http — see Logging & observability.
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.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.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.
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
Write this? [y/N]. The default is no: Enter or n leaves both files byte for byte unchanged.routes.yml edited as textEvery 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, ando,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.
Creates the proxyauth user and group, and the configuration, certificate and database directories with the right ownership.
$ 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.
quickstart vhostSix questions, one new vhosts: entry.
$ proxyauth quickstart vhost
vhost: [...]. Several names share one certificate and one policy; the first one names the certificate paths.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.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.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.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 · yesstatic 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.htmlQuickstart — 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:
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: trueThe 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.
quickstart users$ proxyauth quickstart users
databases is configured. The database is shared by every instance; config.json is local. A login is checked against both either way.must_change_password: the next login lands on the reset page instead of receiving a session. The password is then typed twice, hidden.X-User-Roles.reset-password sends the link. With several addresses, the primary one is asked.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-usercommand is printed, ready for a script. - Into
config.json— an existing username is refused; only the fields you filled are written.config.jsonis read at startup: restart for the account to exist.
quickstart vhost-accessEdits the login gate of a vhost declared in a vhosts: group: allow_users, allow_groups, allow_roles and exclude_users.
$ proxyauth quickstart vhost-access
- 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.
- 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.
- Any exception?
exclude_usersbeats every rule above, even a direct username match.
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.
$ 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.
$ 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.
The wizard does not set up session renewal. Add it to config.json, or to the vhost in routes.yml, and restart:
{
"session_refresh_interval_secs": 900,
"session_refresh_grace_secs": 30
}When something goes wrong
| Message | What to do |
|---|---|
| The generated routes.yml does not parse | The 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 reachable | Check the databases block and connect_timeout_secs. The fallback cache is never written to. |
| undo a run | mv /etc/proxyauth/config/routes.yml.bak-<timestamp> /etc/proxyauth/config/routes.yml |
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.Creates the proxyauth system user and group, and the configuration and certificate directories with the correct ownership and permissions.
$ sudo proxyauth prepare
The minimum viable configuration: a signing secret, a token lifetime, one account. Everything else has a working default.
{
"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.
One vhost, one login page served from disk, one protected application behind it.
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$ sudo systemctl enable --now proxyauth $ curl -I http://localhost:8080/
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.
$ 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.
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.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.
$ sudo proxyauth prepare
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.
{
"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"]
}
]
}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.
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"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.
$ sudo proxyauth certbot new app.example.com $ sudo proxyauth certbot check app.example.com
$ 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.
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.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.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.Concepts & vocabulary #
Six terms used consistently throughout this documentation. Getting them straight makes the rest of the reference read much faster.
| Term | Meaning |
|---|---|
| vhost | A 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. |
| route | A 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. |
| backend | Whatever sits behind a proxied route. One target, or several weighted backends with failover. |
| token | The encrypted credential issued at login. Carried as a bearer token, or as a session_token cookie when session_cookie is on. |
| middleware | Anything applied to a request or response between matching and forwarding: access control, CSRF, filters, compression, tag substitution, header injection. |
| relying party | OIDC 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.
| Scope | File | Reload | Typical use |
|---|---|---|---|
| global | config.json | restart | Listeners, secrets, rate limits, database, logging |
| vhost | routes.yml | live | Sessions, CSRF, TLS, SMTP, login policy, OIDC, maintenance |
| route | routes.yml | live | Access control, caching, compression, headers, filters |
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.
Set-Cookie. The replaced token keeps working for a short grace period, then is refused.POST /refresh_token rotates both, and a reused refresh token revokes the session.required_login route without a valid session is sent to the login page with a 302. API calls keep their 401.session_max_lifetime_secs ends a session a fixed time after login, renewals included.config.json or routes.yml is reported with the setting you most likely meant.config.json at startup, every setting is written back — none are dropped any more.Added in 1.3.0
| Field or behavior | Section | Summary |
|---|---|---|
| session_refresh_interval_secs | Session renewal | Renew a session token once it is older than this. Global, per vhost or per route. |
| session_refresh_grace_secs | Session renewal | How long a replaced token keeps working. Default 30 seconds. |
| session_max_lifetime_secs | Session renewal | Hard end of a session, counted from the original login. |
| predecessor retirement | Session renewal | A 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_seconds | Refresh tokens | Lifetime of the refresh token returned by /auth in JSON mode. 0 (default) turns the feature off. Global, per vhost or per route. |
| POST /refresh_token | Refresh tokens | Exchanges a refresh token for a new pair on the same token id, with reuse detection. |
| redirect_login_url | Login redirect | Where a browser without a valid session is sent. Falls back to logout_redirect_url. Also accepted as login_page_url. |
| session_cookie_age | Per-vhost overrides | Accepted as an alias of max_age_session_cookie. |
| unknown-key warnings | Startup checks | Keys nothing reads are reported at startup, with a suggestion. |
| renewal summary | Startup checks | The 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.
| Area | Before | Now |
|---|---|---|
| /adm/revoke | An 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. |
| /logout | Revocation kept forever. | Kept until the token's own expiry. |
| revocation purge | Ran 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 allowed | 401 carrying the text 403 Forbidden, and the session cleared on one of the two proxy paths. | A real 403; the session is kept. |
| caching | A 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_token | Signing 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 rewrite | Settings 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. |
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 behavior | Section | Summary |
|---|---|---|
| if_vhost | Narrowing a route | Restrict a route to some of the hosts its group declares, validated at startup. |
| mixed vhost lists | Wildcard vhosts | A list holding both literal names and patterns now yields to a host declared explicitly elsewhere. |
Added in 1.2.5
| Field or behavior | Section | Summary |
|---|---|---|
| streaming | Streamed responses | Relay a response as it arrives instead of collecting it in memory first. |
| backend_timeout | Backend timeout | How long a proxied request may take before it is abandoned. |
| vhost: ["*.example.com"] | Wildcard vhosts | A vhost entry may be a pattern covering one extra label. |
| multi-SAN certificates | Let's Encrypt & ACME | Names sharing a vhost_cert land on one certificate instead of overwriting each other. |
| manual DNS-01 | Let's Encrypt & ACME | Wildcard certificates, validated by a TXT record you create. |
Added in 1.2.1 and later
| Field | Section | Summary |
|---|---|---|
| redirect_protect.target | Maintenance mode | Proxy a blocked request to another service instead of serving a file from disk. |
| redirect_protect.paths | Session-checked paths | Path-scoped gates that verify a session against the backend itself. |
| redirect_protect.redirect_url | Session-checked paths | A real 303 See Other destination when a path check fails. |
| hidden_blocks | Hidden blocks | Strip one HTML element from a response instead of blocking the whole path. |
| letsencrypt | Let's Encrypt & ACME | Renewal thresholds, ACME directory and account persistence, under their own config key. |
| strict_mtls | Client certificates | Choose whether a certificate error answers 502 or connects without client authentication. |
Which setup am I? #
Pick by what sits behind the proxy and who talks to it.
| You have | Your clients are | Go to |
|---|---|---|
| One web app with no login of its own | Browsers | Gate one app |
| An HTTP API | Scripts, services, mobile apps | Protect an API |
| A public site with an admin area | Browsers, mostly anonymous | Public site, private admin |
| Several apps on several domains | Different user populations | Several domains |
| An app that already speaks OIDC | Browsers | Hand identity to the app |
| Several instances of one app | Anyone | Spread 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.
session_cookieallow_usersallow_groupsrequired_logingroups · rolesallow_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.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.
{
"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"] }
]
}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: trueThe 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.
<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.comand the two certificate paths — three places.http://127.0.0.1:8000to wherever your app listens.- The accounts. Passwords written in plaintext are hashed in place with Argon2id on first startup.
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.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.
{
"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"] }
]
}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: falseHow a client uses it
$ 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"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.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.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.
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"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.{{ 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.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.
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/, 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.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.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.
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:
$ 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
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.Spread the load across several backends #
Replace target with backends. Weighted round-robin, automatic failover, and a cooldown on anything that times out.
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: -1Close 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.
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: trueBlocked 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.
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.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.
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
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 bytoken_adminalone. Treat that token as a root credential.
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
- 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.ymltherefore always wins over a pattern that would also cover it. Catch-all routes — an emptyvhost— sit in the first pass and keep the rank they have always had. - Regex routes — any route declaring a
regexfield — in the order they appear inroutes.yml. First match wins, the same way nginx trieslocation ~ patternblocks in file order. - Then plain-prefix routes, longest prefix first. A route matching
/api/v2is tried before one matching/api, regardless of which is listed first — the more specific route always gets first refusal. /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.
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.
/ — 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.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.
secretconfig.jsonbuild_hk32 bytesbuild.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_randbuild_epochdate · field orderingmixedAll 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.
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.
fast: falsedefaultBUILD_SEED2. That seed must land in the range 10..=99 — a binary built with anything else refuses to start, with the offending value named.fast: trueThe 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.
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:
Decrypt, check the signature, check the token's own expiry. This is the cacheable part.
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.
Checked against the current token_expiry_seconds, not the value in force when the token was issued.
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.
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.
{
"token_expiry_seconds": 3600,
"session_refresh_interval_secs": 900,
"session_refresh_grace_secs": 30,
"session_max_lifetime_secs": 43200
}session_refresh_interval_secs1.3.0per-vhostinteger · seconds0 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.session_refresh_grace_secs1.3.0per-vhostinteger · secondssession_max_lifetime_secs1.3.0per-vhostinteger · seconds401. A renewed token never outlives this limit. Works with or without renewal.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
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)
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.
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.
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 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.
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
required_login routesession_max_lifetime_secs: the cookie is cleared. The page is served in every case — a public route is never blocked by a session problem.Authorization: Bearer/auth before expires_at.tls, or list the TLS-terminating proxy in trust_proxy_forward_for.token_expiry_seconds signs in again: renewal keeps a session that is used alive, not an abandoned one.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 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.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
- At startup, look for
session renewal every 900s, previous token accepted 30s more.session renewal offmeans the setting is not being read — see Startup checks. - Sign in, wait longer than the interval, then open or reload a page served by ProxyAuth.
- In the browser's developer tools, the response carries
set-cookie: session_token=…andcache-control: private, no-store, max-age=0. The stored cookie value has changed.
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.
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: trueWhich page is the login page
Resolved per route, first match wins:
redirect_login_urlon the route;redirect_login_urlon itsvhosts:group;logout_redirect_urlon the vhost, then inconfig.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.
redirect_login_url1.3.0per-vhoststring/, 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_urlA request without a valid session
GET / HEAD — a pagecurl. An invalid cookie is cleared on the way.302 to the login page, Cache-Control: no-storeAuthorization header401fetch(), XHR, images, scriptsSec-Fetch-Mode other than navigate, or by X-Requested-With.401Accept: application/jsontext/event-stream, unless text/html is accepted too.401POST, PUT, DELETE…401401A signed-in visitor
303 to login_redirect_url/. Earlier versions forwarded every signed-in visit to /, which made a public site unreachable once signed in.403/ is still forwarded to login_redirect_url, exactly as before.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.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.
{
"session_cookie": false,
"csrf_token": false,
"token_expiry_seconds": 600,
"refresh_token_expiry_seconds": 86400
}refresh_token_expiry_seconds1.3.0per-vhostinteger · seconds0 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.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
$ curl -s -X POST https://api.example.com/auth -d 'username=ci-runner&password=...'
{
"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
{"refresh_token": "..."} as JSON, or refresh_token=... as a form200 with a new pair, in the same shape as /auth, sent with Cache-Control: no-storesession_max_lifetime_secs./auth by ratelimit_auth. On an OIDC vhost the path is proxied to the backend, like /auth.$ curl -s -X POST https://api.example.com/refresh_token \
-H 'content-type: application/json' \
-d "{\"refresh_token\": \"$REFRESH\"}"| Status | error | When |
|---|---|---|
| 400 | unsupported_grant_type | Refresh tokens are off for this host: lifetime 0, or session cookies in use. |
| 400 | invalid_request | No refresh_token, or a body that is neither JSON nor a form. |
| 401 | invalid_grant | Invalid, expired or revoked token; an access token; a session past its maximum lifetime; a refresh token already used — which also revokes the session. |
| 403 | access_denied | The 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
/adm/revoke with that id ends the whole session/refresh_token. Versions before 1.3.0 cannot read the mark and refuse refresh tokens too.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.session_max_lifetime_secs counts from the original sign-in.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./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.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.
secretrequiredstringsecret alone is not enough to forge a token without also having the matching build. See Tokens.token_expiry_secondsrequiredinteger(5 years max)
usersarraydatabases-backed accounts.token_adminstringX-Auth-Token header to call every /adm/* endpoint. Treat it like a root credential — those endpoints are not rate-limited.hostarray or stringportinteger1 – 65535
tlsbooleanhttps. 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.workerintegernum_instancesintegerSO_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.max_connectionsintegerpending_connections_limitintegerclient_timeoutinteger · mskeep_aliveinteger · msmax_idle_per_hostinteger0 – 3000
max_body_sizeinteger · bytes10 MB
backend_timeout1.2.5integer · msstreaming — see Backend timeout.socket_listenintegerpending_connections_limit above.fastbooleanstrict_mtlsbooleanfalse connects without client authentication and logs a warning; true answers 502 instead. See Client certificates for which fits which deployment.cache_duration_secsinteger · secondsmax-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.compressionobjectloggingobjectlog, which configures the transport. See Logging.letsencryptobjectcertbot_renew vhost. Also accepted under its original name acme. See Let's Encrypt.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.
{
"backend_timeout": 30000
}scoperedirect_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.00 legitimately means "off" for the rate limits and the refresh intervals.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.
streaming on a route pointing at something you do not operate.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.
login_via_otpper-vhostbooleansession_cookieper-vhostbooleansession_token cookie after authentication, instead of — or alongside — bearer-token auth. Flags set: Secure, HttpOnly, SameSite=Strict.max_age_session_cookieper-vhostinteger · secondstoken_expiry_seconds. Also accepted as session_cookie_age.60 – 31 536 000
session_refresh_interval_secs1.3.0per-vhostinteger · secondsSet-Cookie, without signing in again. 0 disables renewal. See Session renewal.session_refresh_grace_secs1.3.0per-vhostinteger · secondssession_max_lifetime_secs1.3.0per-vhostinteger · seconds0 means no limit.refresh_token_expiry_seconds1.3.0per-vhostinteger · seconds/auth returns in JSON mode, next to the access token and with the same token id. 0 disables refresh tokens. See Refresh tokens.login_redirect_urlper-vhoststringlogout_redirect_urlper-vhoststring/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-vhostbooleantag_csrf_token. See CSRF.cors_originsper-vhostarray or null/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,PUTorDELETEthat a browser attaches anOriginheader 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: *andAccess-Control-Allow-Credentials: trueare 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.
statsboolean/adm/stats and /adm/stats/sessions (still gated by token_admin) and the local proxyauth stats socket. Tracks per-token usage counters in memory.timezonestringPassword reset & SMTP
smtpper-vhostobjectproxyauth reset-password. Optional — omit entirely if unused. Fields: host, port, username, password, from, timeout_secs.page_change_passwordper-vhoststring?token=… appended, to set a new password — after a reset email, or automatically on first login for an account carrying must_change_password.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.
requests_per_secondinteger0 disables rate limiting for that traffic class entirely.burstintegerblock_delayinteger · ms"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.
00/auth only00requests_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./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.Scaling & shared storage #
What a single instance needs in order to behave consistently as one of several.
redisstringdatabasesobjectdb_type ("postgres" or "mysql"), host, port, db_name, user, password. See Database-backed users for the timers and the sync model.blakegateexperimentalarraySharing 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.
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
DashMapin memoryLMDB/opt/proxyauth/db/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.Redisoptionalredis. Without it everything above still works, and a revocation is simply local to the node.How long an entry is kept
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.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.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.
revoked_at + purge date. For a rotated: entry, the second half packs the refusal start and the purge date.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.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:
token:<id><id>_action1 — what the sync loop scans for.<id>_countThe 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.
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
1 · drain RedisSCAN 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.2 · LMDB → memoryrevoke 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.3 · purge4 · shutdownSIGTERM and SIGINT, and flushes LMDB to disk before exiting.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.
PROXYAUTH_TEST_REDIS=redis://127.0.0.1:6379/15 cargo test --test revoke_sync. Without the variable, the Redis cases are skipped.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.
"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"
}check_interval_secsintegercertbot_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.renew_before_daysintegerdirectory_urlstringcontact_emailstringaccount_credentials_pathstringOne 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.
- 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.
*.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.
$ sudo proxyauth certbot new example.com $ dig +short TXT _acme-challenge.example.com
certbot check reports the days remaining._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.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.
whentls 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.wherehost, 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.what it serves301 to the same URL on https:// — which is what operators generally expect from port 80 on an HTTPS-only site anyway.if the port is takenACME: 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./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.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.Operational settings #
run_user / run_groupstringlogobject{"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.ip_blocklistsarrayip_blocklist_refresh_interval_secsnumberip_blocklists sources are re-fetched. 0 fetches once at startup and never refreshes again. Ignored when the list is empty.redirect_protect_refresh_interval_secsnumberredirect_protect.allow_url_ips and deny_url_ips sources are re-fetched — see Maintenance mode. Ignored when no route configures either field.trust_proxy_forward_forarrayX-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.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.
{
"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
sourcerequiredstringhttp(s):// URL or a local file path. One address or CIDR per line, or CSV.namestringsource, with anything not filesystem-safe replaced by _. Worth setting: it is what makes the cache directory readable at a glance.csvbooleancsv_columnintegerWhat it accepts
These are third-party files written to no common standard, so the parser is deliberately forgiving about everything except the address itself.
.gz extension — so a compressed feed served from a URL that does not say so still decompresses.# 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.1.2.3.4 some-hostname works too.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.
/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.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.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.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.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.
usernamerequiredstringpasswordrequiredstringallowarrayrolesarrayX-User-Roles, and usable for route-level access control. See Groups & roles.groupsarrayroles 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--email flags and an optional --primary-email; it is authoritative, not a merge, so omitting it clears whatever was on file.otpkeystring/adm/auth/totp/get on first enrollment. See TOTP enrollment.must_change_passwordbooleanpage_change_password with a fresh single-use token instead of issuing a normal session. Cleared automatically the moment a new password is set.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.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"]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.
targetstringstatic in practice — a route is either proxied or served from disk.staticstringstatic_index.static_indexstringstatic points at a directory — index.html, or a login page.required_loginboolean401 — see Login redirect.redirect_login_url1.3.0string302. Usually set once on the vhosts: group; falls back to logout_redirect_url. Also accepted as login_page_url. See Login redirect.logout_redirect_urlregexstringstatic_rewritestringstatic.preserve_prefixbooleanprefix in the path forwarded to the backend instead of stripping it.allow_methodsarrayallow_ips / deny_ipsarrayredirect_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 / stringsecure 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:
- prefix: "/api"
target: "http://127.0.0.1:8000"routes:
- prefix: "/"
static: "/var/www/app/public/"
static_index: "login.html"Virtual hosts & per-vhost TLS #
A route with no vhost 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.
vhostarrayExample.com:8443 and example.com match the same route. An entry may also be a wildcard pattern such as *.example.com.vhost_certobjectcert and key, both required if either is set. Hot-reloaded on renewal.certbot_renewbooleanSetting 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.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.
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.
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.
*.test.example.comResulta.test.example.comtest.example.coma.b.test.example.com*foo.example.com*. is a pattern; anything else is read as a literal hostname.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.
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"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.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.
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.
if_vhost1.2.7arrayvhost — literally, or through one of its patterns.if_vhost against vhost
Both fields can appear on a route and they answer different questions.
vhostif_vhostvhost_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.if_vhost there is refused at startup rather than silently ignored. Use vhost to declare what such a route serves.if_vhost inherits its group's vhost exactly as before. Every existing routes.yml keeps its current meaning.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.
session_cookiebooleansession_token cookie at all for this vhost, versus bearer-token-only auth.session_cookietag_csrf_tokenbooleanneed_csrf, which is the per-route opt-out once CSRF is already on somewhere.csrf_tokenlogin_redirect_urlstring"/"logout_redirect_urlstring/logout sends the visitor afterward.login_via_otpbooleanpage_change_passwordstringmax_age_session_cookieintegerMax-Age, in seconds. Also accepted as session_cookie_age.redirect_login_url1.3.0stringrequired_login routes. See Login redirect.logout_redirect_urlsession_refresh_interval_secssession_refresh_grace_secssession_max_lifetime_secs1.3.0integersession_refresh_interval_secs: 0 turns renewal off here while it stays on elsewhere. See Session renewal.refresh_token_expiry_seconds1.3.0integer0 turns them off here. See Refresh tokens.cors_originsarraysmtpobjectfrom and timeout must all be set together.smtpvhosts:
- 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"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.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.
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.
allow_usersarrayallow_groups and allow_roles as an OR.allow_groupsarraygroups is.allow_rolesarrayroles is.exclude_usersarrayvhosts:
- 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.
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
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.
/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:
| Endpoint | Purpose |
|---|---|
| GET /.well-known/openid-configuration | Discovery document |
| GET /oidc/jwks.json | Public signing key, for verifying tokens |
| GET, POST /oidc/authorize | Where the browser lands to authenticate. GET shows the login step, POST is its own submission |
| POST /oidc/token | Server-to-server code-for-token exchange |
| GET /oidc/userinfo | Claims about the authenticated user |
| GET /oidc/end-session | RP-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
(+ 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.
required_login off and let the relying party manage its own sessions — that is the division of responsibility the OIDC flow establishes.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.
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"client_idrequiredstring/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_hashrequiredstringredirect_urisrequiredarrayredirect_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_urisarraypost_logout_redirect_uri. Also the fallback destination when a logout request does not specify one. See Logout.scopesarrayopenid is always implicitly required by the protocol regardless of what is listed.login_pagestringGenerating 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:
use argon2::password_hash::{PasswordHasher, SaltString, rand_core::OsRng};
use argon2::Argon2;
let secret = "grafana-super-secret-value"; // keep this, plaintext, for the backend's own config
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(secret.as_bytes(), &salt)
.unwrap()
.to_string();
println!("{hash}"); // this goes in routes.yml's client_secret_hashKeep the plaintext secret for the backend's own configuration. routes.yml only ever needs the hash.
Configuring the backend
Any application 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:
[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-sessionallow_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
$ 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.
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".
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.Access-Control-Allow-Origin: *; a public key is not a secret.response_type=code, client_id, redirect_uri, scope, state, nonce, code_challenge, code_challenge_method=S256client_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.client_secret_basic or client_secret_post, checked before the code itself — a wrong secret never reveals whether the code was otherwise validgrant_type=authorization_code, code, redirect_uri, client_id, client_secret, code_verifierclient_id match → byte-for-byte redirect_uri match (RFC 6749 §4.1.3) → PKCE, SHA-256 of code_verifier, constant-time compared{"access_token", "token_type": "Bearer", "expires_in", "id_token"} — both tokens are signed RS256 JWTs with a one-hour lifetimeAuthorization: Bearer <access_token> — no session-cookie fallback, this is meant to be called server to serversub always; email and email_verified only if the email scope was granted; name only if profile was grantedpost_logout_redirect_uri — optional, falls back to the first entry in logout_redirect_uris; state — optional, echoed backAuthorization 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.
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.
$ 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.
-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:
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.
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.
{{ form }}<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.{{ form_totp }}{{ form }} wholesale.{{ auth_action }}<form method="POST" action="{{ auth_action }}">.Same form, your own branding
<!DOCTYPE html>
<html><body>
<img src="/logo.svg">
{{ form }}
</body></html>Two-step UX, one form and one POST underneath
<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.
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.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.
GET /oidc/end-session
?post_logout_redirect_uri=https://grafana.example.com/login
&state=abc123ProxyAuth 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.
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.[auth.generic_oauth] signout_redirect_url = https://grafana.example.com/oidc/end-session
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.
usernamearraygroupsarraygroups.rolesarrayroles.need_csrfbooleantag_csrf_token.username, groups and roles combine as an OR: a user reaches the route if any condition holds. All three empty means any authenticated account may use 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.
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:
- prefix: "/admin"
target: "http://127.0.0.1:8000"
groups: ["ops"]
roles: ["admin"]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.
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"allow_iparray503 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.allow_url_ipsobjectallow_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_ipsobjectpathstringguess_content_type recognizes, not just HTML. Read fresh on every matching request, never cached, so editing it takes effect immediately.target1.2.1stringpath — 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.1string303 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.1arrayAdmin bypass — the classic use
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:
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
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
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.
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.
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: 200For 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.
regexrequiredstringregex is — searched anywhere in the path by default. Add ^ and $ for an exact match.check_pathrequiredstring"/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"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.expected_statusnumberstatus_code. The exact status that counts as valid. Unset accepts any 2xx; set it to require precisely that code — 200 and not 204.2xxexpected_fieldstringjson. 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_valuestringjson, 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.JSON field check
A stricter alternative for a backend whose logged-out response happens to also be a 2xx:
paths:
- regex: '^/billing(/.*)?$'
check_path: "/api/user"
type_return: "json"
expected_field: "authenticated"
expected_value: "true"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.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.
csrf_tokenboolean · globalconfig.json.tag_csrf_tokenboolean · vhostneed_csrfboolean · routeThe 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_cookieand CSRF are on for the vhost, regardless oftag_proxyauth. - The
tag_proxyauthmechanism — also fills{{ csrf_token }}, on top of the other three tags, on static files and proxied responses alike. It respectstag_csrf_tokentoo: 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:
<!-- 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.
Response headers #
Arbitrary headers — CSP, HSTS, X-Frame-Options, anything else — added to every response a route produces, proxied and static alike.
routes:
- prefix: "/"
target: "http://127.0.0.1:8000"
headers:
Content-Security-Policy: "default-src 'self'; script-src 'self' 'unsafe-inline'"
Strict-Transport-Security: "max-age=63072000; includeSubDomains"
X-Frame-Options: "DENY"Also settable on a vhosts: group. A route's own headers is merged with its group's, not replaced by it — the route's 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.
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
403, a CORS refusal, a 404: there is no route yet, so there are no route headers to apply.Connection, Transfer-Encoding, Upgrade and the rest are regenerated per hop — see Hop-by-hop headers.Cache-ControlStrict-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.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:
- prefix: "/"
target: "http://127.0.0.1:8000"
compression:
enabled: true
algorithm: "br"
min_size: 1024
max_size: 10485760
types: ["text/html", "application/json"]enabledbooleanalgorithmstringAccept-Encoding.min_size / max_sizeinteger · bytestypesarrayEvery 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.
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.
levelintegerlevel_static · file_staticinteger · list".js" or "js", case-insensitive). Static content can afford a slower, denser setting. With no list, everything uses level.leveltypeslistContent-Type, so "text/" covers every text subtype. Images, video, audio and already-compressed formats stay excluded regardless.upstream_identitybooleanAccept-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.What is never compressed
- A body the backend already compressed. A response arriving with
Content-Encodingis 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_sizethe encoding overhead costs more than it saves, andmax_sizeis 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.
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.
vhosts:
- vhost: ["git.example.com"]
streaming: true
routes:
- prefix: "/"
target: "http://127.0.0.1:3000"streaming1.2.5booleanvhosts: group and inherited by every route under it, or on a single route.What changes
Content-Length is emitted.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.
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.
max_body_size — relevant for a large git push, not for a clone.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.Caching #
cachebooleanfalse sends no-store, no-cache, must-revalidate, max-age=0 plus Pragma: no-cache on every response — appropriate for anything showing per-user or otherwise sensitive content.cache_duration_secsintegercache is on, the max-age sent in Cache-Control: public, max-age=… for this route. Falls back to the global cache_duration_secs.Signed-in visitors
The route's policy applies to anonymous visitors. Two cases override it, whatever the route or the backend asked for:
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=0no-store set by the route is kept.private, no-cachecache_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=…cache / cache_duration_secstag_proxyauth: true, render differently for each visitor, signed in or not. Pair them with cache: false.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:
- 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$"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
methodpath/a/../b is matched as /b and cannot be used to slip past a pattern.headername is itself a regex matched against header names, not a literal name.namequeryname is a regex over parameter names, and a parameter repeated in the query string is matched across all its values.namebody_rawbody_jsonContent-Type actually contains application/json — otherwise the condition never matches, regardless of what the body holds.keydefault_allow
default_allowbooleanallow 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.allowarraymethod and path never touches the body at all.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:
- 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 downurlrequiredstringweightinteger-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
GET, HEAD, PUT, DELETE, OPTIONS. A failed POST is reported rather than silently replayed somewhere else.weight: -1 are tried, then the request fails.502max_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.
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.
vhosts:
- vhost: ["app.example.com"]
forward_proxy_headers: true
routes:
- prefix: "/"
target: "http://127.0.0.1:8000"Host$hostX-Forwarded-HostHost.$hostX-Forwarded-Protohttps or http, reflecting how the original request actually arrived.$schemeX-Real-IPX-Forwarded-Fortrust_proxy_forward_for governs elsewhere. Never a blind copy of whatever the client sent.$remote_addrfalse — 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.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.
Connection Keep-Alive
Content-Length Proxy-Connection
Transfer-Encoding Proxy-Authenticate
TE Proxy-Authorization
Trailer
UpgradeRFC 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.
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.
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 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.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:
- 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.
strict_mtls: falsedefaultstrict_mtls: true502. A backend that relies on mTLS to identify ProxyAuth gets an immediate, visible signal the moment a certificate path changes.The two files
A PEM certificate chain and its PEM private key — the pair an internal CA hands out — read by rustls:
file-----BEGIN CERTIFICATE----- blocks, leaf first.keyBEGIN PRIVATE KEY), PKCS#1 (BEGIN RSA PRIVATE KEY) or SEC1 (BEGIN EC PRIVATE KEY). The first key in the file is used.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).
UnsupportedCertVersion, and openssl x509 -req produces v1 unless it is given extensions. Sign client certificates with an extension file:
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.cnfopenssl x509 -in client.crt -noout -text | head -3 shows the version./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 pairSuitsfalsedefaulttrue502 and a loud log line. Nothing reaches the backend without the identity it expects.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.
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.
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.Path handling #
secure_pathbooleantarget 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.preserve_prefixbooleanprefix 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.static_rewritestringregex: 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.secure_pathbooleantarget 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.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.
- 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.
/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.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.
proxybooleanproxy_configstringroutes:
- 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.
proxy_configproxy: true alone has nothing to connect to.certbackends sends every attempt through the same egress proxy.HEAD, which keeps the backend's Content-Length either way, so a route answers the same whether it goes through a proxy or not.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.
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
X-User: alice
X-User-Roles: admin,ops
X-User-Groups: engineeringDatabase-backed users #
An alternative to hand-editing users in config.json: accounts live in PostgreSQL or MySQL instead, shared automatically across every instance pointed at the same database. File-based and database-backed accounts coexist freely — a request is checked against both.
"databases": {
"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
}db_typerequiredstring"postgres" or "mysql".host · port · db_nameuser · passwordstring / integerconnect_timeout_secsintegerrestart can appear to hang, since an attempt already in flight when SIGTERM arrives keeps running.refresh_interval_secsinteger0 disables it, leaving only the full scan.incremental_window_secsintegerrefresh_interval_secs, ideally longer, so a change cannot fall in the gap between two scans and be missed.full_refresh_interval_secsintegerTRUNCATE TABLE users. 0 disables it, and deletions then only take effect on the next restart.deleted_retention_secsintegerpurge_interval_secsintegerSchema
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.
incrementalincremental_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.default 30 s
fullA 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
$ 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
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
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.
idPKusernameuniquepassworddb-add-user hashes before writing.otpkeyNULL for an account that has never enrolled. Written by /adm/auth/totp/get, cleared by the reset endpoint.created_atmodified_atindexedWHERE 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.deleteddb-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.must_change_passwordThe child tables
user_allowallowuser_rolesX-User-Roles for the backend to interpret.rolesgroups + user_groupsUNIQUE (user_id, group_id). See below for why groups get this treatment and roles do not.groupsuser_emailis_primary flag. The primary one is where a reset link is sent.emaildeleted_users_logUPDATE.
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.
db-delete-usersoftdeleted = TRUE and touches modified_at. The row stays until deleted_retention_secs elapses, then the purge removes it for good.DELETE FROM usershardtrg_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.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.
postgresADD COLUMN IF NOT EXISTS, supported natively, so the statement is simply re-run every start.mysqlALTER is attempted unconditionally and a "duplicate column" error is read as "already migrated".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.
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.
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();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.
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;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.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.
file-basedAppState.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.database-backedconfig.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.
{"username": "alice", "password": "..."}409 if a secret already exists — see self-service re-enrollment for the opt-out.X-Auth-Token: <token_admin>{"username": "alice"}Self-service re-enrollment #
/adm/auth/totp/get normally refuses to hand out a new secret to an account that already has one, even with the correct password. 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.
vhosts:
- vhost: ["internal-tool.example.com"]
allow_totp_reenroll: trueWhat 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.
Password reset #
Two related but distinct flows, both landing on the same page_change_password page with a single-use token.
Admin-initiated reset
$ proxyauth reset-password --username alice --vhost app.example.com
Generates a single-use, time-limited link and emails it via smtp. Alice's current password keeps working until she actually follows the link and sets a new one — this 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.
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.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
pathcreated byproxyauth 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.readformattext/plain. There is no HTML variant — the body is sent exactly as written.Structure
The first line must be the subject, in this exact shape, and it is stripped from the body before sending:
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{{ username }}{{ reset_link }}page_change_password with token=<token> appended — joined with ?, or with & if that URL already carries a query string.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:
465587 takes.Credentials are always sent — username and password are not optional — and timeout_secs bounds the whole exchange.
What the link is worth
lifetimereset-password and the automatic one issued on a must_change_password login.single usestorage/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.purgeemail 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.{{ 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.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
| File | Contains | Written by |
|---|---|---|
| /var/log/proxyauth/proxyauth.log | Diagnostics — warnings, errors, startup messages. | the local transport |
| /var/log/proxyauth/access.log | One 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
localdefault/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.lokihosthttpwrite_max_logsdisabledconfig.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.
"log": {
"type": "loki",
"host": "http://loki.internal:3100"
}applabelproxyauth. This is the label you select on: {app="proxyauth"}.pidfieldnum_instances is above 1 — they all carry the same label otherwise.INFO and above reaches Loki. Trace and debug stay local.proxyauth*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
"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
}
}enabledbooleanlog.formatstringformat-log or format_log.log_filestring/var/log/proxyauth/. Give a filename, not a path — absolute paths and ../ are rejected at startup.vhostsmapenabled 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.routesmaplog: 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_msintegerresource_sample_interval_secsinteger[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.Format placeholders
The default:
[time] [[vhost]] [[ip]] - [method] [protocol] [status] [length] [path] [tid:[token-id]] '[user-agent]' '[referer]' [request-time-ns]| Group | Placeholders |
|---|---|
| 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:
| Canonical | Also 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] |
[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.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
logboolean · routelog → logging.routes[prefix] → logging.enabled; only an unset route falls through to the next level.log_filestring · route/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.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.
$ proxyauth stats
requests/sec (last): 42
avg req/sec (10s): 38.50
avg req/sec (60s): 35.20
total requests: 918273
active sessions: 7
uptime: 1d 1h 2m 03srequests_per_secondavg_rps_10s · avg_rps_60s0 until enough samples exist.total_requestsuptime_secondsactive_sessionsnum_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./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
GET /adm/statsproxyauth stats, as JSON, for a monitoring system on another host.token_adminGET /adm/stats/sessions/adm/revoke when a session has to go.token_adminBoth are outside the rate limits, so a monitoring system is never throttled during an incident, and both require "stats": true like the socket itself.
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.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.
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
config.jsonacme alias.config.jsonroutes.ymlvhosts: group and every route, compared with the route fields and their aliases (static, csrf_token, session_cookie_age, login_page_url, certbot_rew).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:
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.
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.
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.
$ proxyauth sync export Key export Success (secret key encrypted with AppConfig password) # written to the current working directory
scp, a shared volume, however you would move any other secret. Place them in /etc/proxyauth/import/ on the target.
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.
$ proxyauth sync
sync export to produce a current pair.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.CLI reference #
Every proxyauth subcommand switches to run_user automatically, reading it from config.json while still root. The command itself still needs to be launched as root so that switch is possible.
Setup & lifecycle
prepare[--insecure]proxyauth user and group, and the config and certificate directories with correct ownership and permissions.--insecure relaxes permissions — local testing onlyGuided setup
quickstart vhostvhosts: entry: hostnames, TLS, browsers or API, who may sign in, the application, the login page. See Quick start.quickstart usersconfig.json, with an Argon2id hash, groups, roles, email and an optional temporary password.quickstart vhost-accessallow_users, allow_groups, allow_roles and exclude_users on a vhost, replacing what was there.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
db-add-user--username U [--password P] [--email E…] [--primary-email E] [--must-change-password]--password prompts interactively with hidden input.--email is authoritative, not a merge — omitting it clears any address on filedb-delete-user--username Udatabases.deleted_retention_secs — 24 hours by default.db-sync-cache[--force]db-restore-from-cache[--force]db-clear-cacheAccounts & access
reset-password--username U [--vhost V]--vhost to use that vhost's own SMTP and reset pageusers[--list]groups[--list]roles[--list]routes-auditcheck-access--username Ucheck-routesTLS & certificates
certbot renew<vhost|all> [--force]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>certbot new<vhost>--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
sync exportSessions 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.
session_cookie: true — dashboards, interfacessession_cookie: false — APIs, scripts/auth answers303 See Other to login_redirect_url, with a session_token cookie attached.200 with {"token", "expires_at"}Authorization: Bearer <token> on each request{{ csrf_token }}.csrf_token: false. A bearer header is never attached automatically, so there is nothing to forge.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 itfetch 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
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 API flow
$ 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.
X-User: alice
X-User-Roles: admin,ops
X-User-Groups: engineeringA 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.
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
username, password, plus csrf_token when CSRF is on for the vhost and a TOTP code when login_via_otp is.ratelimit_auth, independently of proxied traffic. OPTIONS is answered for the CORS preflight against cors_origins.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
session_token cookie. Nothing else is needed, and a missing or already-expired cookie is not an error.logout_redirect_url — the vhost's own value if it sets one, otherwise the global. With no redirect configured, answers 200./adm/revoke with the token ID./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.
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.
/auth/logoutAdmin API reference #
Every /adm/* route requires X-Auth-Token: <token_admin>, with the two TOTP endpoints as documented exceptions.
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.stats: true in config.jsonproxyauth stats reads locally over the Unix socket.token, password, verif_passwordpage_change_password form submits. ProxyAuth appends ?token=… to that URL itself; the page there posts back here. See Password reset.{"token_id": "..."} — an exp field is still accepted, and ignoredtoken_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.200 Token revoked with expiration. · 400 for an empty id or an internal rotated: key · 401 without a valid X-Auth-Token{"username": "alice"}