Docs/Introduction
ProxyAuth documentation

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.

41
documented sections
6
OIDC endpoints
1.2.2
current release
Apache 2.0
license
Getting started

Installation #

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

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

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

What it does, in order

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

Everything it writes

The complete list. Nothing outside these paths is modified.

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

How it runs afterwards

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

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

Before you go to production

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

Replace the self-signed certificate

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

Set your own secret and accounts

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

Choose a log destination

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

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

Quick start #

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.

Scope
Session cookies always carry Secure, HttpOnly and SameSite=Strict, so a browser stores them over plain HTTP only on localhost, which every major browser exempts. This path is therefore for local development and for API clients, which do not use cookies at all. For a real hostname, use the TLS tab — it takes one extra command.
Prepare the host

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

shell
$ sudo proxyauth prepare
Write config.json

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

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

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

Write routes.yml

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

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

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

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

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

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

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

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

shell
$ sudo proxyauth prepare
Write config.json

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

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

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

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

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

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

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

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

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

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

What happens at renewal

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

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

Concepts & vocabulary #

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

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

Where each setting lives

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

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

Release highlights — v1.2.2 #

Nine additions, all opt-in. An existing routes.yml and config.json keep working unchanged until you reach for one of them.

Identity
A vhost can act as a genuine OpenID Connect provider. The backend behind it receives a real, independently verifiable id_token.
Backends
forward_proxy_headers sends Host, X-Forwarded-* and the real client IP the way nginx's proxy_set_header would.
Middleware
redirect_protect gates a whole vhost behind an IP allow-list, serving a static or proxied maintenance page to everyone else.
Multi-tenancy
Session cookies, CSRF, redirects, SMTP and CORS can all differ from one vhost to the next.
Multi-tenancy
Decide who is even allowed to log in on a given vhost, independently of what a route lets them reach afterward.
Middleware
{{ username }}, {{ csrf_token }} and two more, substituted in static files and proxied responses alike.
Identity
Enrollment and reset behave identically whether an account lives in config.json or a shared database.
Identity
An explicit, narrow opt-out from admin-mediated 2FA resets, for the vhosts where that trade-off makes sense.
Operations
proxyauth stats reads straight from a Unix socket — no HTTPS round-trip, no admin token.

Added in 1.2.1 and later

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

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

Which setup am I? #

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

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

The three decisions every setup makes

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

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

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

Gate one app behind a login page #

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

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

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

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

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

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

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

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

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

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

The login page

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

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

What to change

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

Protect an API #

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

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

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

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

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

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

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

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

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

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

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

How a client uses it

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

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

Public site, private admin #

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

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

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

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

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

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

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

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

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

Several domains on one instance #

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

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

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

    session_cookie: true
    login_via_otp: true
    max_age_session_cookie: 1800
    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
Why this works
Both vhosts declare a route at /, and each request reaches its own. Matching is vhost-aware end to end, so the two entries are independent: you can add a third domain, reorder them, or split them across files without any of them affecting the others. See Routing.
Note
exclude_users beats every allow rule. contractor-temp stays out of the internal tool even while remaining in the staff group and keeping access to everything else.
Common setups

Hand identity to an app that speaks OIDC #

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

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

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

    allow_groups: ["staff"]

    oidc:
      client_id: "grafana"
      client_secret_hash: "$argon2id$v=19$m=19456,t=2,p=1$..."
      redirect_uris:
        - "https://grafana.example.com/login/generic_oauth"
      logout_redirect_uris:
        - "https://grafana.example.com/login"
      scopes: ["openid", "profile", "email"]

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

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

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

Spread the load across several backends #

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

/etc/proxyauth/config/routes.yml
vhosts:
  - vhost: ["app.example.com"]
    vhost_cert:
      cert: "/etc/proxyauth/cert/app.example.com/fullchain.pem"
      key: "/etc/proxyauth/cert/app.example.com/privkey.pem"
    certbot_renew: true

    allow_groups: ["staff"]
    session_cookie: true

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

Close the doors for a deployment #

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

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

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

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

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

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

Architecture

Request lifecycle #

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

flowchart TD A(["Request arrives"]) --> B{"IP on a
blocklist?"} B -->|yes| R1(["403 — never reaches routing"]) B -->|no| C{"ACME challenge
path?"} C -->|yes| R2(["Served — certificate renewal
is never gated"]) C -->|no| 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
Simplified — response middleware only runs on routes that opted into it.

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 by token_admin alone. Treat that token as a root credential.
Architecture

Routing & route matching #

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

Evaluation order

  1. Regex routes first — any route declaring a regex field — in the order they appear in routes.yml. First match wins, the same way nginx tries location ~ pattern blocks in file order.
  2. Then plain-prefix routes, longest prefix first. A route matching /api/v2 is tried before one matching /api, regardless of which is listed first — the more specific route always gets first refusal.
  3. / is always tried last among prefix routes, whatever its listed position. It is the most generic possible prefix, so it only catches what nothing more specific claimed.

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

flowchart TD A(["Incoming request"]) --> B{"Any regex routes
defined?"} B -->|yes| C["Try each, in routes.yml order —
first pattern match wins"] C -->|matched| VH1{"vhost also
matches Host?"} C -->|none matched| D B -->|no| D["Try plain-prefix routes,
longest prefix first"] D --> VH2{"vhost also
matches Host?"} VH1 -->|yes| USE(["Route selected"]) VH2 -->|yes| USE VH1 -->|no| D VH2 -->|more candidates left| D VH2 -->|no candidates left| NONE(["404 — no route matched"]) classDef start fill:#1a1d25,stroke:#e8ff47,stroke-width:2px,color:#ffffff classDef decision fill:#2a2410,stroke:#e8ff47,stroke-width:2px,color:#ffffff classDef step fill:#14161a,stroke:#7a8296,stroke-width:1.5px,color:#ffffff classDef success fill:#0f2416,stroke:#22c55e,stroke-width:2px,color:#ffffff classDef error fill:#2a1215,stroke:#ef4444,stroke-width:2px,color:#ffffff class A start class B,VH1,VH2 decision class C,D step class USE success class NONE error

Prefix matching, precisely

A request path matches a route's prefix if it is an exact match, or if it starts with the prefix followed by /. So prefix: "/api" matches /api and /api/users, but not /apikeys — the boundary has to land on a real path segment, not just a shared string of characters.

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

Tokens & sessions #

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

Two secrets, not one

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

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

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

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

The obfuscation pass

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

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

The verification cache

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

Sizing
The cache is per process, so total memory scales with 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:

Vault verification

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

User lookup by index

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

Expiry policy

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

Revocation set

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

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

config.json
The server itself: what it listens on, what signs its tokens, how it talks to a database, and where it logs. Everything below lives in this one file, at the top level.
Scopeglobal — the whole instance
Reloadrestart required
Default path/etc/proxyauth/config/config.json
config.jsonServer & networking

Server & networking #

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

FieldDescriptionDefault
secretrequiredstring
Cryptographic signing secret that tokens are derived from — a random value of 64 characters or more. Combined at startup with a 32-byte per-build salt via HKDF-SHA256 into the actual encryption key, so secret alone is not enough to forge a token without also having the matching build. See Tokens.
token_expiry_secondsrequiredinteger
Seconds before an issued token expires.
1 – 31 536 000
(5 years max)
usersarray
File-based accounts. See The User object. Merged at request time with any databases-backed accounts.
[]
token_adminstring
Admin token, required in the X-Auth-Token header to call every /adm/* endpoint. Treat it like a root credential — those endpoints are not rate-limited.
hostarray or string
Address or addresses ProxyAuth listens on. A bare string is accepted for backward compatibility; an array lets one instance bind several addresses at once, IPv4 and IPv6 together for instance.
0.0.0.0
portinteger
Listening port.
8080
1 – 65535
tlsboolean
Enable HTTPS/TLS. Required on any vhost acting as an OIDC provider — an issuer must be https. When this is on and at least one route sets certbot_renew, a second plain-HTTP listener is bound on port 80 automatically; see Let's Encrypt.
true
workerinteger
Worker threads per instance. Should roughly match available CPU cores.
4
num_instancesinteger
Number of ProxyAuth processes to launch. Total concurrency is num_instances × worker.
2
max_connectionsinteger
Maximum simultaneous connections accepted.
50000
pending_connections_limitinteger
Backlog of connections waiting to be accepted.
65535
client_timeoutinteger · ms
How long ProxyAuth waits for a client to finish sending its request.
5000
keep_aliveinteger · ms
Keep-alive duration for idle client connections.
5000
max_idle_per_hostinteger
Maximum idle pooled connections kept open per backend host, reused across requests instead of renegotiating each time.
50
0 – 3000
max_body_sizeinteger · bytes
Largest request body accepted before the request is rejected outright.
10485760
10 MB
socket_listeninteger
Listen backlog passed to the socket itself, distinct from pending_connections_limit above.
1024
fastboolean
Trades request-time flexibility for raw throughput on the hot proxy path, and skips the extra cost function in token verification.
false
strict_mtlsboolean
How a route's client certificate (mTLS) is handled when it cannot be read or fails to pair. false connects without client authentication and logs a warning; true answers 502 instead. See Client certificates for which fits which deployment.
false
cache_duration_secsinteger · seconds
Default max-age for every route with cache: true and no override of its own. 0 disables caching at the HTTP layer even when cache is on — the header becomes max-age=0.
300
compressionobject
Global compression defaults, overridden per vhost group or per route. See Compression.
loggingobject
Access-log line format and the global, per-vhost and per-route on/off switches. Deliberately separate from log, which configures the transport. See Logging.
letsencryptobject
ACME renewal settings shared by every certbot_renew vhost. Also accepted under its original name acme. See Let's Encrypt.
config.jsonAuthentication & sessions

Authentication & sessions #

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

FieldDescriptionDefault
login_via_otpper-vhostboolean
Require a TOTP code at login, in addition to username and password.
false
session_cookieper-vhostboolean
Issue and check a session_token cookie after authentication, instead of — or alongside — bearer-token auth. Flags set: Secure, HttpOnly, SameSite=Strict.
false
max_age_session_cookieper-vhostinteger · seconds
Session cookie lifetime.
3600
60 – 31 536 000
login_redirect_urlper-vhoststring
Where an already-authenticated visitor, or a fresh successful login, is sent.
"/"
logout_redirect_urlper-vhoststring
Where /logout sends the visitor afterward. Also used to resolve which page to annotate with an error message on a login failure — see CSRF § Error pages.
csrf_tokenper-vhostboolean
Master switch for CSRF protection. Overridden per vhost under the name tag_csrf_token. See CSRF.
true
cors_originsper-vhostarray or null
Origins permitted to make cross-origin, credentialed requests to auth-related endpoints (/auth, /adm/auth/totp/get, /logout). null disables cross-origin access entirely.
  • A same-origin request is never blocked by this list, checked or not — including a POST, PUT or DELETE that a browser attaches an Origin header to even when it matches the page already loaded. The comparison is against the actual scheme and host the request arrived on, since this allow-list only ever meant to restrict genuinely external callers.
  • Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true are never sent on the same response — browsers reject that combination outright, so the wildcard that OIDC discovery and JWKS rely on stays valid regardless of credentials headers configured elsewhere.
null
statsboolean
Enables /adm/stats and /adm/stats/sessions (still gated by token_admin) and the local proxyauth stats socket. Tracks per-token usage counters in memory.
false
timezonestring
IANA timezone used for token timestamps.
Europe/Paris

Password reset & SMTP

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

Rate limiting #

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

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

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

Scope
Rate limiting covers /auth and proxied traffic. /adm/* is outside both classes so an operator or a monitoring system is never throttled during an incident; those endpoints are gated by token_admin instead.
config.jsonScaling & shared storage

Scaling & shared storage #

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

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

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

config.jsonLet's Encrypt

Let's Encrypt & ACME #

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

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

The port 80 listener

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

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

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

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

Operational settings #

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

The User object #

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

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

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

Core route fields #

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

FieldDescriptionDefault
prefixrequiredstring
The URL prefix this route matches. See Routing for the full algorithm.
targetstring
The backend URL to proxy this route to. Mutually exclusive with static in practice — a route is either proxied or served from disk.
staticstring
A file or directory path served directly from disk, with no backend involved. A directory also needs static_index.
static_indexstring
The file served for the directory root when static points at a directory — index.html, or a login page.
required_loginboolean
Whether a valid session is required to reach this route at all. Works for proxied and static routes alike.
false
regexstring
Match this route by regular expression instead of by prefix. Regex routes are tried first, in file order — see Routing.
static_rewritestring
Rewrites the path before resolving it against static.
preserve_prefixboolean
Keep the matched prefix in the path forwarded to the backend instead of stripping it.
false
allow_methodsarray
HTTP methods this route accepts. Unset accepts every method.
allow_ips / deny_ipsarray
IP and CIDR allow / deny lists for this route specifically, IPv6 included. Distinct from redirect_protect.allow_ip, which is a maintenance gate serving alternative content, and from the global ip_blocklists, which run before routing. Parsed and validated once at startup, so the request path only does a containment check. An entry that is not a valid address or CIDR stops startup with the route, field and value named — a restriction is either in force or reported.
[]
proxy / proxy_configboolean / string
Forward through an intermediate HTTP proxy. See Egress proxy.
false
Renamed
secure is now required_login. Startup validation checks for the old name and reports every route still using it, by prefix, so a configuration written against an earlier version is migrated with the list in hand rather than by searching for it.
routes.yml — proxied route
routes:
  - prefix: "/api"
    target: "http://127.0.0.1:8000"
routes.yml — static route
routes:
  - prefix: "/"
    static: "/var/www/app/public/"
    static_index: "login.html"

routes.yml · vhostVirtual hosts

Virtual hosts & per-vhost TLS #

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

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

Setting this on a single route also opens port 80 for the whole instance. When tls is on and any route in routes.yml has this flag, ProxyAuth binds a separate plain-HTTP listener on port 80 at startup, on every address in host. It answers ACME challenges and redirects everything else to HTTPS, with nothing to configure. See Let's Encrypt & ACME for what happens on a host where port 80 is already in use.
false
routes.yml — grouping routes under one vhost
vhosts:
  - vhost: ["app.example.com"]
    vhost_cert:
      cert: "/etc/proxyauth/cert/app.example.com/fullchain.pem"
      key: "/etc/proxyauth/cert/app.example.com/privkey.pem"
    certbot_renew: true
    routes:
      - prefix: "/"
        target: "http://127.0.0.1:8000"
      - prefix: "/admin"
        target: "http://127.0.0.1:8001"

A vhosts: group is purely an authoring convenience. It is flattened into plain routes right after parsing, so nothing downstream — routing, TLS SNI resolution, the CLI audit tools — knows the form exists. Each route inherits the group's vhost, vhost_cert, certbot_renew and every per-vhost override, unless it sets its own. Mixing both styles in one file is fine, and a route inside a group can still override the group's values.

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

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.

FieldDescriptionDefault
session_cookieboolean
Whether ProxyAuth issues and checks a session_token cookie at all for this vhost, versus bearer-token-only auth.
global session_cookie
tag_csrf_tokenboolean
Whether CSRF protection — injection and server-side validation — is on at all for this vhost. Genuinely independent per vhost, unlike need_csrf, which is the per-route opt-out once CSRF is already on somewhere.
global csrf_token
login_redirect_urlstring
Where an already-authenticated visitor is sent instead of the login form, and where a fresh login lands on success.
global value, or "/"
logout_redirect_urlstring
Where /logout sends the visitor afterward.
global value
login_via_otpboolean
Whether a TOTP code is required at login, in addition to username and password.
global value
page_change_passwordstring
The external page a password-reset link points visitors at, for this vhost's accounts.
global value
max_age_session_cookieinteger
The session cookie's Max-Age, in seconds.
global value
cors_originsarray
Origins allowed to make cross-origin requests to this vhost. Whole-list replacement, not merged with the global list.
global list
smtpobject
The SMTP server used to send this vhost's password-reset emails, letting different domains send through different mail servers. Whole-object replacement — host, port, credentials, from and timeout must all be set together.
global smtp
routes.yml — two vhosts, two mail servers
vhosts:
  - vhost: ["app.example.com"]
    session_cookie: true
    tag_csrf_token: true
    login_redirect_url: "/app"
    logout_redirect_url: "/"
    login_via_otp: true
    page_change_password: "https://app.example.com/lost-password.html"
    max_age_session_cookie: 3600
    cors_origins: ["https://app.example.com"]
    smtp:
      host: smtp.app.example.com
      port: 587
      username: noreply@app.example.com
      password: "..."
      from: "App <noreply@app.example.com>"
      timeout_secs: 10
    routes:
      - prefix: "/"
        target: "http://127.0.0.1:8000"

  - vhost: ["other.example.com"]
    smtp:
      host: smtp.another-domain.com
      port: 587
      username: noreply@other.example.com
      password: "..."
      from: "Other <noreply@other.example.com>"
      timeout_secs: 10
    routes:
      - prefix: "/"
        target: "http://127.0.0.1:9000"
Where to set these
These describe a domain rather than a path, so a vhosts: group is their natural home — set once, applied to every route under it. Individual routes accept the same fields when one of them needs to differ.
routes.yml · vhostVirtual hosts

Login authorization #

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

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

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

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

flowchart TD A(["Login attempt on this vhost"]) --> B{"Username in
exclude_users?"} B -->|yes| DENY(["Denied"]) B -->|no| C{"allow_users,
allow_groups and
allow_roles
all empty?"} C -->|yes| DENY C -->|no| D{"Username in
allow_users,
OR in an allowed
group, OR holds
an allowed role?"} D -->|yes| OK(["Login proceeds —
session and route access
checks continue normally"]) D -->|no| DENY classDef start fill:#1a1d25,stroke:#e8ff47,stroke-width:2px,color:#ffffff classDef decision fill:#2a2410,stroke:#e8ff47,stroke-width:2px,color:#ffffff classDef success fill:#0f2416,stroke:#22c55e,stroke-width:2px,color:#ffffff classDef error fill:#2a1215,stroke:#ef4444,stroke-width:2px,color:#ffffff class A start class B,C,D decision class OK success class DENY error

routes.yml · vhostOIDC provider

Overview & flow #

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

Note
This is the opposite direction from ProxyAuth's own login system, and both coexist. Nothing about /auth, session cookies or TOTP changes for vhosts that do not set oidc:. Where it is set, the backend decides who is authenticated, not ProxyAuth's own required_login and access-control checks.

What changes on an OIDC-enabled vhost

ProxyAuth's own auth machinery steps aside entirely — not just required_login, but CSRF validation and injection, the Authorization header forwarding decision, and route-level username, groups and roles access control too, 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:

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

Every endpoint except discovery lives under /oidc/ — kept 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

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

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

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

Configuration #

One oidc: block per vhost, registering exactly one relying party. Deliberately one client per vhost rather than a list: ProxyAuth's model is already one vhost, one backend everywhere else, and matching that keeps the block answering a single question — which backend, at which callback URL, may receive tokens for this vhost's identity.

routes.yml
vhosts:
  - vhost: ["grafana.example.com"]
    tls: true                        # required — an OIDC issuer must be https
    oidc:
      client_id: "grafana"
      client_secret_hash: "$argon2id$v=19$m=19456,t=2,p=1$..."
      redirect_uris:
        - "https://grafana.example.com/login/generic_oauth"
      logout_redirect_uris:
        - "https://grafana.example.com/login"
      scopes: ["openid", "profile", "email"]
      login_page: "/etc/proxyauth/pages/login.html"
    routes:
      - prefix: "/"
        target: "http://127.0.0.1:3000"
FieldDescriptionDefault
client_idrequiredstring
The identifier the backend presents at /oidc/token, and that appears in tokens as the aud claim. Not secret — meant to be public, the same way a browser's client_id in a public OAuth flow is.
client_secret_hashrequiredstring
Argon2id hash of the client secret, verified the same way a user password is and never stored or compared as plaintext. See below for generating one.
redirect_urisrequiredarray
Exact-match only — no prefix or wildcard matching. Every redirect_uri a client sends to /oidc/authorize must appear here byte for byte. This is what guards against the classic OIDC open-redirect-via-authorization-code attack.
logout_redirect_urisarray
Same exact-match discipline, for RP-Initiated Logout's post_logout_redirect_uri. Also the fallback destination when a logout request does not specify one. See Logout.
[] — clears the session, never redirects
scopesarray
Scopes this client may request. openid is always implicitly required by the protocol regardless of what is listed.
["openid","profile","email"]
login_pagestring
Path to a static HTML file replacing the built-in login form. See Custom login page.
built-in form

Generating client_secret_hash

Pick a long random value for the secret itself — treat it like an API key, not a password a human types, so no length or memorability constraints apply — then hash it with Argon2id the way ProxyAuth hashes everything else. There is no dedicated CLI command for this yet; until there is, any small script using the same argon2 crate works:

rust
use argon2::password_hash::{PasswordHasher, SaltString, rand_core::OsRng};
use argon2::Argon2;

let secret = "grafana-super-secret-value"; // keep this, plaintext, for the backend's own config
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
    .hash_password(secret.as_bytes(), &salt)
    .unwrap()
    .to_string();
println!("{hash}"); // this goes in routes.yml's client_secret_hash

Keep the plaintext secret for the backend's own configuration. routes.yml only ever needs the hash.

Configuring the backend

Any application speaking standard OIDC configures against the same URLs. Most need only the discovery URL and auto-configure the rest. Grafana wants each one spelled out:

grafana.ini
[auth.generic_oauth]
enabled = true
name = ProxyAuth
client_id = grafana
client_secret = grafana-super-secret-value
scopes = openid profile email
auth_url = https://grafana.example.com/oidc/authorize
token_url = https://grafana.example.com/oidc/token
api_url = https://grafana.example.com/oidc/userinfo
redirect_uri = https://grafana.example.com/login/generic_oauth
allow_sign_up = true
signout_redirect_url = https://grafana.example.com/oidc/end-session

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

Verifying

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

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

HTTP APIOIDC provider

Endpoint reference #

Each check below is enforced in the order given. Every one earns its place in the chain that turns "someone has a code" into "here is cryptographic proof of who logged in".

GET/.well-known/openid-configuration
ReturnsThe standard OIDC discovery document — every other endpoint's URL, supported scopes, signing algorithm (RS256 only), and code_challenge_methods_supported: ["S256"]. Always Access-Control-Allow-Origin: *: any OIDC client library, from any origin, needs to fetch this to configure itself. The same wildcard applies to this endpoint's OPTIONS preflight, and a caller's cors_origins allow-list never applies here.
GET/oidc/jwks.json
ReturnsThe public half of the RS256 signing key, in JWKS format — what lets a standard OIDC client library verify a token without talking to ProxyAuth directly. Also always Access-Control-Allow-Origin: *; a public key is not a secret.
GET, POST/oidc/authorize
Paramsresponse_type=code, client_id, redirect_uri, scope, state, nonce, code_challenge, code_challenge_method=S256
Behaviorclient_id and redirect_uri are validated first and independently — an invalid one shows an error page directly, never a redirect, since that is the exact mechanism an open-redirect-via-OAuth attack relies on. GET either issues a code immediately, when a valid ProxyAuth session already exists, or renders the login step. POST is that same form's submission, verifying credentials and establishing the session before issuing the code. PKCE is mandatory, with no confidential-client exemption.
POST/oidc/token
Authclient_secret_basic or client_secret_post, checked before the code itself — a wrong secret never reveals whether the code was otherwise valid
Paramsgrant_type=authorization_code, code, redirect_uri, client_id, client_secret, code_verifier
ChecksClient auth → atomic single-use code validation → client_id match → byte-for-byte redirect_uri match (RFC 6749 §4.1.3) → PKCE, SHA-256 of code_verifier, constant-time compared
Returns{"access_token", "token_type": "Bearer", "expires_in", "id_token"} — both tokens are signed RS256 JWTs with a one-hour lifetime
GET/oidc/userinfo
AuthAuthorization: Bearer <access_token> — no session-cookie fallback, this is meant to be called server to server
Returnssub always; email and email_verified only if the email scope was granted; name only if profile was granted
GET/oidc/end-session
Paramspost_logout_redirect_uri — optional, falls back to the first entry in logout_redirect_uris; state — optional, echoed back
BehaviorClears the ProxyAuth session cookie unconditionally, then redirects if the URI is on the exact-match list, otherwise shows a plain confirmation page. See Logout.

Authorization codes

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

Token verification, precisely

id_token carries iss, sub, aud, exp, iat and nonce, 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.

CLIOIDC provider

Signing key setup #

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

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

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

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

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

routes.yml · vhostOIDC provider

Customizing the login page #

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

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

Same form, your own branding

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

Two-step UX, one form and one POST underneath

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

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

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

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

Logging out #

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

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

ProxyAuth clears its own session cookie unconditionally. If post_logout_redirect_uri is present and matches logout_redirect_uris exactly, the browser is redirected there with state echoed back. If it is absent entirely, ProxyAuth falls back to the first entry in logout_redirect_uris — so a backend's sign-out configuration can point at /oidc/end-session with no query string at all and still land somewhere sensible. A present-but-unrecognized URI shows a plain confirmation page instead of guessing.

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

routes.yml · routeMiddleware

Access control #

Route-level access control — what an already logged-in visitor can reach. Not to be confused with vhost login authorization, which decides whether someone can log in on a vhost at all.

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

username, groups and roles combine as an OR: a user reaches the route if any condition holds. All three empty means any authenticated account may use it.

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

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

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

Maintenance mode #

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

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

Admin bypass — the classic use

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

Fetched allow and deny lists

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

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

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

Proxied maintenance page

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

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

routes.yml · vhostMiddleware

Session-checked paths #

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

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

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

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

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

JSON field check

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

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

Hidden blocks #

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

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

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

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

CSRF #

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

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

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

Where the token gets filled in

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

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

Error pages

Applies when session_cookie is on. Errors are embedded in an HTML comment block for the page to reveal:

html — error block format
<!-- BEGIN_BLOCK_ERROR -->
<!--
  <div id="error_display">
    {{ error }}
  </div>
-->
<!-- END_BLOCK_ERROR -->

On a login failure, ProxyAuth uncomments this block and fills {{ error }} in. On success, the block stays commented out and invisible. The page this happens to is resolved the same vhost-aware way any other request is routed — critical on a multi-vhost instance, where more than one vhost commonly has its own route at the exact same path.

routes.yml · routeMiddleware

Dynamic tags #

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

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

Why tag_proxyauth is opt-in

Off by default, and — unlike most per-route settings — not inherited-then-defaulted-on anywhere. Substitution reads the whole response body as text and scans it on every matching request, so it is enabled per route, for exactly the content that uses these tags. Only text/html responses are scanned even where it is on.

A login page served entirely as static files

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

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

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

For a first-time visitor: {{ username }} stays literal text since there is nobody to greet, {{ csrf_token }} is filled with a real token so the form works, and {{ proxyauth_version }} resolves regardless of login state.

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

Response headers #

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

routes.yml
routes:
  - prefix: "/"
    target: "http://127.0.0.1:8000"
    headers:
      Content-Security-Policy: "default-src 'self'; script-src 'self' 'unsafe-inline'"
      Strict-Transport-Security: "max-age=63072000; includeSubDomains"
      X-Frame-Options: "DENY"

Also settable on a vhosts: group. A route's own headers is merged with its group's, not replaced by it — the route's entries win only on a key both define. That lets you set HSTS once for a whole vhost, then override one header on a specific route.

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

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

Compression #

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

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

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

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

Caching #

FieldDescriptionDefault
cacheboolean
Whether responses from this route may be cached downstream at all. false sends no-store, no-cache, must-revalidate, max-age=0 plus Pragma: no-cache on every response — appropriate for anything showing per-user or otherwise sensitive content.
true
cache_duration_secsinteger
When cache is on, the max-age value sent in Cache-Control: public, max-age=… for this route's static files.
Per-visitor content
Routes serving a login page, or anything with tag_proxyauth: true, render differently for each visitor. Pair them with cache: false.
routes.yml · routeMiddleware

Request filters #

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

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

Condition types

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

default_allow

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

routes.yml · routeBackends

Load balancing #

A route can point at several backends instead of one — weighted round-robin, with automatic failover. A request that times out against one backend is retried against the next, not dropped.

routes.yml
routes:
  - prefix: "/api"
    backends:
      - url: "http://10.0.0.1:8000"
        weight: 2
      - url: "http://10.0.0.2:8000"
        weight: 1
      - url: "http://10.0.0.3:8000"
        weight: -1   # failover only — no traffic unless the others are all down
FieldDescriptionDefault
urlrequiredstring
The backend's URL.
weightinteger
Relative share of traffic — weight 2 against weight 1 means roughly twice the requests. -1 removes a backend from normal rotation entirely, used only when every weighted backend is currently down.

A backend that times out is put on a short cooldown rather than retried immediately on every subsequent request, avoiding a thundering herd of failed attempts against something that is genuinely down.

routes.yml · vhost + routeBackends

Forwarding client info #

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

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

Client certificates (mTLS) #

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

routes.yml
routes:
  - prefix: "/secure-api"
    target: "https://internal-service:8443"
    cert:
      file: "/etc/proxyauth/certs/client.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.

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

Path handling #

FieldDescriptionDefault
secure_pathboolean
Forces every request on this route to be forwarded strictly to the exact target URL, ignoring the request's own sub-path. Prevents sub-path traversal and open-redirect-style tricks against the backend.

Example: with target: http://localhost:8000/api/endpoint, a request for /api/endpoint/../other still forwards to exactly /api/endpoint.
false
routes.yml · routeBackends

Egress proxy #

Route a proxied request through another HTTP proxy before it reaches the real target — for a backend only reachable through a corporate egress proxy, for instance.

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

config.jsonroutes.yml · route

Groups & roles #

Alternatives to enumerating individual usernames on every route, for when the same set of people needs access to many routes.

A user reaches a route if any of three conditions hold: their username is listed in username, they belong to an allowed groups entry, or they hold an allowed roles entry. Both are checked against the matching fields on the User object, or the equivalent database columns.

roles vs. groups — a real distinction

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

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

Database-backed users #

An alternative to hand-editing users in config.json: accounts live in PostgreSQL or MySQL instead, shared automatically across every instance pointed at the same database. File-based and database-backed accounts coexist freely — a request is checked against both.

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

Schema

Six tables. users carries the username, password hash, otpkey, timestamps, a soft-delete flag and must_change_password. user_allow, user_roles, groups with user_groups, and user_email carry the one-to-many fields — mirroring the file-based User object's shape exactly, so the two backends behave identically from the application's point of view.

Sync model

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

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

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

Local fallback cache

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

shell
$ proxyauth db-add-user --username alice --password '...' --roles admin --groups ops
$ proxyauth db-delete-user --username alice
$ proxyauth db-sync-cache            # force an immediate full scan
$ proxyauth db-restore-from-cache    # rebuild the database from the local cache
$ proxyauth db-clear-cache
HTTP APIUsers & identity

TOTP enrollment #

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

Two storage backends, two ways of staying in sync

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

BackendHow the in-memory view is kept currentRestart needed
file-based
AppState.config is loaded once as an immutable snapshot. An in-memory overlay, checked before that snapshot at both login and re-enrollment time, bridges the gap until the next restart.
no
database-backed
ProxyAuth keeps a mutable in-memory mirror of the database for speed. That mirror is refreshed for the one affected user immediately after every write — no overlay needed, since this mirror is meant to be mutable in the first place.
no
flowchart TD A(["Enrollment / reset request"]) --> B{"Found in
config.json?"} B -->|yes| C["Write config.json,
update in-memory overlay"] B -->|no| D{"Database
configured?"} D -->|yes| E["Write to the database,
refresh in-memory mirror,
patch LMDB fallback cache"] D -->|no| F(["Error: user not found anywhere"]) C --> G(["Change is live immediately —
no restart needed"]) E --> G classDef start fill:#1a1d25,stroke:#e8ff47,stroke-width:2px,color:#ffffff classDef decision fill:#2a2410,stroke:#e8ff47,stroke-width:2px,color:#ffffff classDef step fill:#14161a,stroke:#7a8296,stroke-width:1.5px,color:#ffffff classDef success fill:#0f2416,stroke:#22c55e,stroke-width:2px,color:#ffffff classDef error fill:#2a1215,stroke:#ef4444,stroke-width:2px,color:#ffffff class A start class B,D decision class C,E step class G success class F error

Why the fallback cache is patched too

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

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

Self-service re-enrollment #

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

/adm/auth/totp/get normally refuses to hand out a new secret to an account that already has one, even with the correct password, 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.

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

What changes on that vhost

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

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

CLIUsers & identity

Password reset #

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

Admin-initiated reset

shell
$ proxyauth reset-password --username alice --vhost app.example.com

Generates a single-use, time-limited link and emails it via smtp. Alice's current password keeps working until she actually follows the link and sets a new one — this does not lock her out immediately, it gives her a way back in without needing the old password.

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

Forced change on first login

An account created with must_change_password: true — typically right after an admin sets a temporary password — is redirected to page_change_password on its very next successful login, instead of getting a normal session. The flag clears automatically the moment a new password is set.

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

config.jsonOperations

Logging & observability #

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

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

Two files, on purpose

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

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

Transports — the log block

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

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

Grafana Loki

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

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

The access line — the logging block

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

Format placeholders

The default:

default format
[time] [[vhost]] [[ip]] - [method] [protocol] [status] [length] [path] [tid:[token-id]] '[user-agent]' '[referer]' [request-time-ns]
GroupPlaceholders
request[method] [path] [query] [protocol] [host] [route]
client[ip] [x-forwarded-for] [user-agent] [referer]
identity[username] [token-id] [vhost]
response[status] [length] [request-time] [error_detail]
process[time] [cpu-usage] [memory-usage]
Tip
Shipping to Loki and want structured queries rather than regex parsing? Write the format as key=value pairs — method=[method] status=[status] path=[path] user=[username] ms=[request-time] — and Loki's logfmt parser picks the fields up directly.

Per-route overrides

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

Stats socket #

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

shell
$ proxyauth stats
output
requests/sec (last):  42
avg req/sec (10s):     38.50
avg req/sec (60s):     35.20
total requests:        918273
active sessions:       7
uptime:                1d 1h 2m 03s
FieldMeaningType
requests_per_second
The last completed one-second sample, not a rolling figure.
integer
avg_rps_10s · avg_rps_60s
Rolling averages over the last 10 and 60 samples. Both read 0 until enough samples exist.
float
total_requests
Since this process started. Counters are padded to avoid false sharing, so requests on different workers almost never contend on the same cache line.
integer
uptime_seconds
Process uptime.
integer
active_sessions
Distinct tokens currently counted as active.
integer
Scope of these figures
Counters are per process. With num_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.
Note
The socket lives at /opt/proxyauth/run/stats.sock, created by the server at startup. Its filesystem permissions — owner-only, inside a directory that is itself owner-only — are the authentication, doing the same job token_admin does for the equivalent GET /adm/stats endpoint, which remains available for monitoring systems not running on the same host.
CLIOperations

SSO across instances #

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

Export from the source instance

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

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

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

Import on the target instance

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

shell — target instance
$ proxyauth sync
Version-checked
An import is validated before anything is adopted: a keystore carrying a build secret shorter than the current 32 bytes is refused with the reason named, so an importing instance always ends up with key derivation at full strength. Upgrade the source instance and re-run sync export to produce a current pair.
Prerequisite
Both instances need the same secret in config.json for this to work at all — secret is what protects key.asc's private key material in the first place. Sync covers the other half — the per-build constants — which a matching secret alone does not carry.
CLIOperations

CLI reference #

Every proxyauth subcommand switches to run_user automatically, reading it from config.json while still root. The command itself still needs to be launched as root so that switch is possible.

Setup & lifecycle

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

Database-backed users

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

Accounts & access

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

TLS & certificates

CommandDescriptionNotes
certbot renew<vhost|all> [--force]
Renews now, via the same native ACME mechanism the periodic scan uses. A named vhost only needs vhost_cert configured — running the command by hand is itself sufficient intent, certbot_renew is not required. all is stricter: it only touches vhosts that did opt into automatic renewal. Without --force, a certificate that is not yet due is left alone and the command exits without contacting Let's Encrypt at all.
certbot check<vhost|all>
Certificate status and days remaining.
certbot new<vhost>
Issues a certificate for a vhost that does not have one yet — unconditionally, no --force, since there is nothing to compare against a threshold. Does not accept all. vhost_cert must already be set for it. Restart afterwards if the vhost was only just added.

Operations

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

Admin API reference #

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

Treat token_admin as a root credential
token_admin is the sole gate on these endpoints, and they are exempt from rate limiting so administration and monitoring keep working under load. Scope access accordingly — an allow_ips route, a private interface in host, or a firewall rule in front.
GET/adm/stats
Requiresstats: true in config.json
ReturnsRequests per second (last, 10s average, 60s average), total requests, active sessions, uptime — the same data proxyauth stats reads locally over the Unix socket.
GET/adm/stats/sessions
ReturnsPer-token usage counters — which token IDs have been used, how many times, and by whom.
POST/reset-password
AuthThe single-use token from the reset link — not the admin token. Public by necessity.
Bodytoken, password, verif_password
BehaviorWhere the page_change_password form submits. ProxyAuth appends ?token=… to that URL itself; the page there posts back here. See Password reset.
GET/adm/logs
ReturnsRecent access-log entries, filterable. See the wiki for the query parameter reference.
POST/adm/revoke
Body{"token_id": "..."}
BehaviorAdds the token ID to the revocation set checked on every subsequent token verification. A structurally valid, unexpired token stops working immediately. Synced across instances via Redis when configured.
POST/adm/auth/totp/get
AuthUsername and password in the body, not the admin token
BehaviorFirst-time TOTP enrollment. See TOTP enrollment.
POST/adm/auth/totp/reset
Body{"username": "alice"}
BehaviorClears a user's TOTP secret so they can re-enroll. See TOTP enrollment.