One instance. Many vhosts.
Each with its own rules.
ProxyAuth is a reverse proxy that authenticates and authorizes requests before they ever reach your application. 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 that lets a single instance serve several domains with genuinely independent behavior.
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 #
Two paths to a working instance, both starting from an installed host. Pick the one matching where you are deploying — the plain-HTTP path is for a local machine or a network you already trust, the TLS path is what you want anywhere a browser will actually reach it.
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"
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.2.2 #
Nine additions, all opt-in. An existing routes.yml and config.json keep working unchanged until you reach for one of them.
id_token.forward_proxy_headers sends Host, X-Forwarded-* and the real client IP the way nginx's proxy_set_header would.redirect_protect gates a whole vhost behind an IP allow-list, serving a static or proxied maintenance page to everyone else.{{ username }}, {{ csrf_token }} and two more, substituted in static files and proxied responses alike.config.json or a shared database.proxyauth stats reads straight from a Unix socket — no HTTPS round-trip, no admin token.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"
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
login_redirect_url: "/app"
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| 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| R6(["Redirect to login"]) G -->|no| 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"] 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,E,F,G,H,I decision class D,J,K step class Z,R2 success class R1,R3,R4,R5,R6,R7,R8 error
Three 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.
- 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
- Regex routes first — 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 — four processes hold up to four times the figure above. Each keeps its own working set, and a session that lands repeatedly on the same instance benefits fully from it.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.
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 nine 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_instancesintegernum_instances × worker.max_connectionsintegerpending_connections_limitintegerclient_timeoutinteger · mskeep_aliveinteger · msmax_idle_per_hostinteger0 – 3000
max_body_sizeinteger · bytes10 MB
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.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 · seconds60 – 31 536 000
login_redirect_urlper-vhoststringlogout_redirect_urlper-vhoststring/logout sends the visitor afterward. Also used to resolve which page to annotate with an error message on a login failure — see CSRF § Error pages.csrf_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.
/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.
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_pathstringThe 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_blocklistsarraysource (URL or local file path), optional name for the local cache file at /etc/proxyauth/abuse/<name>.txt, and csv plus csv_column (0-indexed) to pull the address out of a CSV column instead of the first whitespace-separated token on the line. Plain text or gzip, auto-detected.ip_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.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_loginbooleanregexstringstatic_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.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, and a route inside a group can still override the group's values.
Per-vhost overrides #
Nine settings that were global-only in 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.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, since none of them mean anything once the backend handles its own auth. Left unchecked, that last one specifically could reject a session the OIDC flow itself just correctly established, if the route happens to carry an unrelated groups: restriction from before it went OIDC-enabled.
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 visually and structurally distinct from 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, both rendering the form on GET and processing its submission on POST, rather than detouring through the global /auth. The proof that reaches the backend at the end is a signed token instead of a header or cookie.
The full flow
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, meant to be verified by the relying party's own OIDC library 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, which also checks that the token's iss matches 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.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 at all 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.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.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 value sent in Cache-Control: public, max-age=… for this route's static files.tag_proxyauth: true, render differently for each visitor. 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.
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.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.p12"
password: "..."Connections to the same backend are pooled and reused across requests rather than renegotiating TLS every time. The pool is keyed by target and certificate together, so two routes pointing at the same backend with different client certificates each get their own pool 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.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.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_configstringGroups & 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. roles can also now gate route access directly, the same way groups does — the two mechanisms coexist without conflict, since 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 read-only from the application's point of view for almost everything: db-add-user and db-delete-user always require a live connection and refuse outright if the database is unreachable, rather than writing to the cache instead.
The one exception is TOTP enrollment 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
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. But an enrollment or reset patches it immediately alongside the real database write — specifically so a database outage occurring shortly afterward cannot cause ProxyAuth to fall back to a stale, since-replaced secret.
{"username": "alice", "password": "..."}409 if a secret already exists — see self-service re-enrollment for the opt-out.X-Auth-Token: <token_admin>{"username": "alice"}Self-service re-enrollment #
/adm/auth/totp/get normally refuses to hand out a new secret to an account that already has one, even with the correct password, so that replacing an enrolled second factor 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.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] |
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 above 1, several processes share the port and the socket answers from one of them, so a whole-host rate is the sum across instances — roughly the reported figure times num_instances under even distribution. GET /adm/stats returns the same structure over HTTP for a monitoring system to scrape and aggregate./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.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 onlyDatabase-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.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.Operations
sync exportAdmin 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": "..."}{"username": "alice"}