Authentication¶
Three different front doors, deliberately separate:
| Door | Who it is for | Where it is checked |
|---|---|---|
| The management UI | you, administering this app | this app, per node |
| Basic auth on a service | visitors to a published service | HAProxy, from a userlist |
| Single sign-on on a service | visitors, via an OIDC provider | HAProxy, from a signed cookie |
A service uses basic auth or single sign-on, never both. The source-address controls compose with either, and with neither. None of these share accounts: a service user cannot open this UI, and this UI's administrator is nobody special to a service.
The management UI¶
Each node has one administrator, stored per node — set it on each node, or
let Apply to the other nodes do it (below). The password is stored only
as a PBKDF2 hash. Signing in issues a session cookie signed with a per-node
secret; sessions last session_hours (12 by default), and rotating the
node's session_secret signs everyone out of that node.
Changing the password or the username asks for the current password — a
session alone is not enough to change how you sign in. When the cluster has
other nodes, the account dialog offers Apply to the other nodes, ticked
by default: it sends the stored salt and digest — never the password — to
each node's /api/admin/receive, authenticated by that node's API key. A
browser session on the receiving node cannot reach that endpoint; only the
key can.
Two-factor authentication is optional, per administrator, from the same
dialog: standard TOTP, six digits every thirty seconds. Setup shows a QR
code and completes only when the current code proves the authenticator holds
the secret; eight single-use recovery codes are shown once. A code cannot be
replayed, and Apply to the other nodes carries the second factor with the
login. The escape hatch for a lost phone is app.py disable-2fa on the
node's shell.
The API key (Cluster → This node) is the machine door: automation and
the other nodes present it as X-API-Key, and /metrics accepts it as a
bearer token. It is node-local and never synced.
Basic auth on a service¶
A service can ask visitors for a user name and password — HTTP basic authentication, checked by HAProxy itself, so a request without valid credentials is answered 401 and never reaches the servers behind it.
Tick Require a sign-in in the publish wizard (or on a Backend Pool under Advanced), and manage who may answer under Sign-in → Users and Sign-in → Groups:
| Field | Notes |
|---|---|
| User name | letters, digits, and . - _ @ — it travels in a configuration line and comes back from a browser |
| Password | at least 8 characters, stored only as a SHA-512 crypt ($6$) hash — the format HAProxy reads. Leaving the field empty when editing keeps the current one. |
| Groups | which groups the user belongs to |
| Enabled | off keeps the account but stops it signing in anywhere |
On the service:
| Field | Notes |
|---|---|
| Require a sign-in | HTTP services only — a raw TCP port has nowhere to carry one. Use it over HTTPS: on plain HTTP the credentials cross the network in the clear. |
| Allowed groups | none ticked admits any user; otherwise only members of those groups. A service admits groups rather than people, so access changes by moving someone in or out of a group. |
| Sign-in prompt | the realm the browser shows above its password box; defaults to the pool name |
| Skip the sign-in from | networks trusted without a password, typically the LAN — 192.168.1.0/24 browses freely while everyone else meets the password box |
What ends up in haproxy.cfg is a userlist and one line per protected
pool:
userlist ham_users
group staff
user alice password $6$... groups staff
backend be_shop
http-request auth realm "The Shop" unless { http_auth_group(ham_users) staff }
Two edges are deliberate. A group that a service admits cannot be deleted
while that service admits it — the request is refused and names the service.
And a service that requires a sign-in nobody can satisfy (no users with a
password yet, or every group it admitted is gone) renders as http-request
deny: refusing everyone is safer than quietly becoming public.
Users and groups are shared across the cluster, because the services that check them are — a failover meets the same credentials. The backup carries them without the password hashes, like every other secret, so users restored from a backup need a password set again; the users already on the node keep theirs.
Source-address controls¶
Beside the sign-in options in the same wizard section:
- Allowed networks — one address or CIDR per line; requests from
anywhere else are refused. Unlike a sign-in this works for
tcp://services too (tcp-request content reject), which is how a database port is kept to the LAN. An entry that does not parse is refused at the form; one that somehow reaches the renderer anyway is left out, which only ever narrows who gets in — and a list with no readable entries refuses everyone rather than admitting everyone. - The addresses HAProxy tests are the TCP source — behind another proxy that is the proxy's address, not the visitor's.
Single sign-on (OIDC)¶
Instead of a password box, a service can send its visitors through an OpenID Connect provider — Authentik, Keycloak, Authelia, Pocket ID, Google, Entra: anything that answers OIDC discovery. One sign-in covers every protected service; who may enter stays a per-service decision.
Setting it up¶

Sign-in → Single sign-on holds the provider, once:
| Setting | Notes |
|---|---|
| Issuer URL | must be https://; its /.well-known/openid-configuration is read from here. Test proves it before saving. |
| Client ID / secret | from the provider's client registration; a blank secret keeps the stored one |
| Sign-in host | e.g. auth.example.com — point its DNS at the virtual IP; HAProxy routes exactly /.ham-sso/ on it to this app and answers 404 for anything else. It needs certificate coverage on the HTTPS listener (a wildcard is enough). |
| Cookie domain | the domain the session spans, e.g. example.com. The sign-in host and every protected service must sit under it. Never a bare public suffix (com, co.uk) — browsers refuse such cookies. |
| Scopes | openid email profile unless the provider needs otherwise |
| Session length | default 12 hours. Single sessions cannot be revoked (nothing is stored anywhere); Rotate secret is the kill switch and signs everyone out everywhere. |
| Accept unverified email claims | off by default; see unverified emails |
Register the redirect URI the page shows —
https://<sign-in host>/.ham-sso/callback — at the provider. The page
carries step-by-step setup instructions for authentik, Authelia and Google,
with your real hostnames filled in.
On the service — the publish wizard, or a Backend Pool under Advanced:
| Field | Notes |
|---|---|
| Require single sign-on | HTTP services only; tcp:// cannot carry a redirect. Not together with basic auth — one sign-in per service. |
| Allowed identities | one per line: an email, a whole domain as @example.com, or a literal * for anyone the provider signs in. An empty list refuses everyone — "anyone" is never a silent default, because with a public provider that would mean every account it has. |
| Pass the signed-in email to the servers | sets identity headers for apps that trust a proxy identity; see what the upstream sees |
How a sign-in actually runs¶
- An unauthenticated request to a protected service meets HAProxy, which finds no valid session cookie and redirects the browser to the sign-in host, carrying the original URL.
- This app (reached only through that host) redirects on to the provider's
authorization endpoint — authorization-code flow with PKCE, a signed
state, and a nonce cookie that binds the transaction to this browser. - The visitor authenticates at the provider — their password only ever exists there.
- The provider sends the browser back to
/.ham-sso/callback; the app exchanges the code over TLS, vets the claims (issuer, audience, expiry, nonce, verified email), and sets the session cookie:Domain=<cookie domain>; Secure; HttpOnly— expiry, the address, and an HMAC signature over both. - The browser lands back on the exact page it first asked for. From here on, HAProxy alone verifies the cookie on every request — signature (timing-safe), expiry, and the service's allow-list — in generated configuration, with this app out of the traffic path.
A signed-in visitor who is not on a service's list gets a 403, not another
trip to the provider. The signing secret is shared configuration, so every
node validates every session and a failover signs nobody out. Signing out at
https://<sign-in host>/.ham-sso/logout clears the cookie; the provider's
own session is the provider's business.
What the upstream application sees¶
Nothing, by default. The password never leaves the provider; the session cookie is stripped from requests before they are forwarded — on every service, so no upstream app ever holds a token that opens the others — and the request arrives looking anonymous.
Apps that want the identity — Grafana's auth-proxy mode and its relatives
— get it by ticking Pass the signed-in email to the servers, which sets
X-Auth-Request-Email and Remote-User from the verified session. Client-
sent copies of those headers are deleted on every service whether or not it
forwards, so the header is always the proxy's word: a forgery meets an empty
header, never a passed-through one. Only safe while the app is reachable
through the proxy alone.
Unverified emails¶
Authorization is the email string, so it is only as true as the provider's
word for it. A provider sending email_verified: false is refused by
default — on a provider where people edit their own profile, an unverified
email is just a text field anyone can set to anyone. Keycloak marks
admin-created users unverified until Email verified is switched on; prefer
flipping that. Accept unverified email claims exists for providers that
cannot say verified at all, and the warning lives where the switch is. An
absent claim is trusted — some providers simply never send one.
When something refuses¶
- "the provider says that email address is unverified" — see above.
- "the identity provider did not answer" on Test or sign-in — the
issuer URL is wrong, or its certificate does not verify. A provider behind
a private CA needs
REQUESTS_CA_BUNDLEpointing at the CA file in this app's environment:Environment=REQUESTS_CA_BUNDLE=/etc/ssl/private-ca.pemin a systemd drop-in, or-e REQUESTS_CA_BUNDLE=...with a volume for Docker. - The provider complains about the redirect URI — what is registered
there must match
https://<sign-in host>/.ham-sso/callbackexactly. - Signing in loops or never sticks — the cookie domain is wrong: a bare public suffix (browsers refuse the cookie), or the service's host does not sit under it (the cookie is never sent back).
- 403 after a successful sign-in — the sign-in worked; that service's allow-list does not include the signed-in address. That is the design: fix the list, not the session.
- "this sign-in was started by a different browser" — the callback arrived without the nonce cookie the login set. Usually a stale bookmark of the provider's page or a cookie-blocking extension on the sign-in host.
- A stolen or leaked session — Rotate secret. Every session on every service stops verifying at the next request.
The trust model, in one place¶
- The ID token arrives on a private TLS connection to the token endpoint, authenticated by the client secret; its claims are checked (issuer, audience, expiry, nonce) rather than its signature — the channel vouches for it, per OIDC Core 3.1.3.7, and every request afterwards is verified against our own HMAC instead.
- PKCE and a browser-bound state stop injected authorization codes and login CSRF; the return address is rebuilt from vetted parts, so the callback cannot be used as an open redirect.
- Every enforcement decision after sign-in is HAProxy configuration:
timing-safe signature comparison, expiry, allow-list. The app being down
does not sign anyone out, and a half-configured service fails closed —
http-request deny, never quietly public. - The signing secret appears (base64) in the generated
haproxy.cfg, which is root-only — the same trust level as the basic-auth hashes already there.