Web Admin
A browser- and script-facing management interface for the server: the accounts it has registered, the orders it has issued, the EAB credentials it honours, and the nonce table.
It is a second listener, on its own socket, serving no ACME — and it is off by default. A certificate authority should not grow a management surface because somebody upgraded it.
It has two faces over the same operations: HTML pages at /ui for a
browser, and a JSON API at /api for a script. Neither is built on the
other; both are thin layers over the same crates/admin/src/admin/ operations
the CLI calls.
[admin]
enabled = true
There is no sign-up page and never will be. The first operator is created from a shell on the host — see Users & Sessions.
Why a second listener
The ACME listener is public, unauthenticated, and frequently internet-facing. Everything about its defaults follows from that: admission control that sheds load, a filter chain, a small body limit.
The admin listener has the opposite shape. It defaults to loopback, requires a
session on every route but sign-in, and deliberately carries no admission
control (the availability concern here is credential brute force, which the
login rate limiter handles and admission control would not touch). It does not
inherit the profiles’ [filter] either: it has a policy of its own,
[admin.filter].
Access control on this listener is the bind address, TLS, [admin.filter], and
the session.
Putting both on one socket would have meant one set of defaults for two very different threat models.
Exposing it
The default binds loopback only:
[admin]
bind_address = "127.0.0.1:3001"
base_url = "http://localhost:3001"
The recommended way to reach it from elsewhere is an SSH tunnel, which needs no configuration change at all:
$ ssh -N -L 3001:127.0.0.1:3001 ca.example.com
Then open http://localhost:3001 locally. admin.base_url stays as it is,
because from the browser’s point of view the panel really is on localhost.
Binding to a real interface
Startup refuses a non-loopback bind_address while admin.tls.enabled is
false:
admin.bind_address `0.0.0.0:3001` is not loopback while admin.tls.enabled is
false: the session cookie is sent `Secure`, which a browser will not store over
plain HTTP on anything but localhost, so signing in would appear to succeed and
then fail silently. Set admin.tls.enabled = true, or bind 127.0.0.1 and reach
it through an SSH tunnel
This is an error rather than a warning on purpose. The session cookie is always
sent Secure — making that conditional is how a session cookie leaks — and
browsers accept a Secure cookie on http://localhost but silently refuse it
on http://192.0.2.10:3001. The operator would see “sign-in works, then I am
immediately signed out” with nothing in any log to explain it.
So a public bind means TLS:
[admin]
enabled = true
bind_address = "0.0.0.0:3001"
base_url = "https://admin.example.com:3001"
[admin.tls]
enabled = true
As with [server.tls], a self-signed certificate for the host of
admin.base_url is generated on first start when the files are missing, and the
key is created 0600. The paths default to admin.pem/admin.key, separate
from the ACME listener’s — the two answer to different names and should not
share a certificate by accident.
Give the panel its own host name
Every response carries Strict-Transport-Security: max-age=31536000; includeSubDomains. Once a browser has seen that over HTTPS it will refuse plain
HTTP for the whole host, and for every name under it, for a year — which is
the point when the panel has a host of its own, and a trap when it does not.
The scope is the host in admin.base_url, so
base_url = "https://admin.example.com:3001" commits admin.example.com and
anything below it, and nothing else. But
base_url = "https://example.com:3001" commits every subdomain of
example.com, including services that have nothing to do with this server and
may not speak HTTPS at all. HSTS is not scoped by port, so running on :3001
does not narrow it.
Give the panel a name of its own. There is no configuration key for the header: weakening it for everyone is the wrong trade when a dedicated host name costs a DNS record.
Nothing to worry about while admin.tls.enabled is false and the bind is
loopback — a browser ignores the header entirely over plain HTTP (RFC 6797
§7.2), which is why it is emitted unconditionally rather than gated.
Restricting who can reach it
Where a host firewall can guard the port, use it. Where it cannot — a container
runtime owns the host’s netfilter tables, so an nftables rule on the host is
not yours to write — [admin.filter] puts the same policy engine the ACME
listener uses in front of every admin request, /health included:
[admin]
enabled = true
bind_address = "0.0.0.0:3001"
base_url = "https://admin.example.com:3001"
[admin.tls]
enabled = true
[admin.filter]
rules = ["mgmt"]
[admin.filter.check.mgmt-net]
type = "allowed_ip"
allow = ["10.20.0.0/24", "127.0.0.1/32"]
[admin.filter.rule.mgmt]
when = "mgmt-net"
then = "allow"
A caller outside 10.20.0.0/24 gets 403 before any handler runs: the admin
API’s access_denied error under /api, the HTML error page elsewhere. Only
checks that read the request itself make sense here — allowed_ip, path,
reverse_dns, custom — and startup refuses the others by name. The check and
rule syntax is the one Filters documents; the section’s
own rules are in the
Configuration Reference.
Two things are worth knowing in a container:
- Container networking rewrites addresses. Published ports usually keep the
client’s address, but some setups (rootless Docker’s port forwarder, a
userland proxy) make every connection appear to come from the bridge
gateway. Check the
client_ipon the admin access line before relying on an allowlist. - A reverse proxy in front (Traefik, Caddy, nginx) is every peer. List its
network in
admin.filter.trusted_proxies, and the policy, the access line and the sign-in rate limiter all see the real client from itsX-Forwarded-For. The loopback-or-TLS rule above still applies to the bind: a proxy in another container reaches the panel over the network, so keep[admin.tls]on and have the proxy speak HTTPS to it.
A health check run inside the container (Docker’s HEALTHCHECK) arrives from
loopback, so allow 127.0.0.1/32 as above. An orchestrator probing from
outside needs a type = "path" check for /health and a rule allowing it.
Authentication
Sign-in exchanges a username and password for an opaque session token:
- The token is 256 random bits, and only its SHA-256 is stored. A database
read — a backup, a
.dump, an injection — yields nothing replayable. - It travels in a
__Host-acme_admin_sessioncookie,HttpOnly; Secure; SameSite=Strict; Path=/. The__Host-prefix is browser-enforced: it requires those attributes, so an edit that drops one breaks visibly. - Sessions have both an absolute lifetime (
session_ttl_seconds, never extended by activity) and an idle timeout (session_idle_timeout_seconds). - Passwords are hashed with PBKDF2-HMAC-SHA256 at 600 000 iterations. A failed
sign-in costs the same whether the username exists or not, so the endpoint
cannot be used to enumerate operators — and every failure returns the same
invalid_credentials, whatever the real cause. The log says which.
CSRF
Every request with an unsafe method must carry an X-CSRF-Token header matching
the session’s csrfToken, which sign-in and GET /api/session both return.
SameSite=Strict is set as well, but it is not sufficient on its own here,
and the reason is specific: SameSite is scoped to the registrable domain, not
the origin — different ports of the same host are same-site. A panel on
:3001 beside anything else on :8080 of the same box is exactly what it does
not cover.
There is also an origin gate: a request whose Origin does not match
admin.base_url, or whose Sec-Fetch-Site says cross-site, is refused. That
is what covers sign-in itself, which by definition carries no session token yet.
Both headers are checked only when present, so a script or curl is unaffected.
Roles
A session also carries the operator’s role
(Users → Roles). A mutating request is refused with
403 insufficient_role unless the role permits it: a CA action (revoke, delete
an account or order, EAB, nonce sweep) needs operator or admin; the
/operators/* and /ui/operators/* colleague-management routes (including a
colleague’s role and notification address) need admin; the own-account routes
(password, notification address, own sessions, own second factor, sign-out)
need only a live session. An operator whose row predates the feature is admin.
Reads are open to every role with one exception: the /operators surface needs
admin to read as well as to act, on both front ends. A tier that gated
only the writes would be a control over what a colleague can do and not over
what they can learn — and that surface carries every operator’s role and contact
address, the addresses each recently signed in from, and every live session’s
fingerprint. So GET /api/operators, /api/operators/{username} and
/api/operators/{username}/sessions, and the two /ui/operators pages, answer
403 insufficient_role to an operator or a viewer.
SameSite=Strictalso means clicking a link into the panel from another site will not carry your session. For an admin panel that is a feature, but it surprises people.
The panel
Open admin.base_url in a browser: / redirects to /ui/, and anything under
it that needs a session bounces to /ui/login.
| Page | What is on it |
|---|---|
/ui/ | What needs attention — failed jobs, certificates expiring within seven days, refusals in the last day, and any endpoint bypassing validation — each linking to its list; then totals for accounts, orders, EAB credentials and nonces, and the mounted endpoints |
/ui/accounts | Every account, filterable by profile and EAB credential, listed with the address its key was last seen from; a detail page carries both recorded addresses, the contact editor, deactivate and delete |
/ui/orders | Every order, filterable by profile, status, account, identifier (exact or substring) and certificate serial; a detail page shows the authorizations and challenges, offers the issued chain for download, and revokes or deletes |
/ui/eab | Credentials, paged; minting (the secret is shown once); a detail page counts the accounts it registered, links to them, and revokes or deletes — keeping, deactivating or deleting those accounts |
/ui/expiring | Certificates lapsing inside a window, soonest first, each annotated with whatever has already replaced it; filterable by profile and window, with a control to hide the replaced ones. Read-only |
/ui/jobs | The background queue — relayed issuance, notification delivery, the periodic sweeps — filterable by kind and status. A detail page cross-links a relay job to its upstream order and carries Cancel and Run now (see below) |
/ui/upstream-orders | One row per local order relayed to an upstream CA: the upstream URLs, the upstream’s own error text, the local order status. Read-only — stop an in-flight relay from /ui/jobs instead |
/ui/audit | The CA’s audit trail — every issuance and every refusal, filterable, with a detail page per row. Read-only: there is no route here that prunes it |
/ui/nonces | The table size, and a manual sweep |
/ui/profiles | The endpoints this process serves, and a warning for any that bypass validation |
/ui/profiles/{name}/filter | One endpoint’s resolved access policy: every check, and every rule in evaluation order with its condition re-parenthesized. Read-only |
The account pages surface the CA’s account-side traceability columns: where
newAccount was called from and the reverse name that address had at the time
(Created from), and when and where the key last authenticated a request
(Last seen, Last seen from). Each is shown only when it was recorded — a
reverse lookup that found nothing leaves the address alone, and an estate where
it can never succeed leaves both names blank rather than every row saying
“unknown”. Nothing in the server ever compares against them; they answer “who
asked for this certificate, and from where”, not “may this request proceed”.
How it is built
[htmx], vendored into the binary — no npm, no build step, no CDN. The templates are [minijinja] and can be overridden on disk without rebuilding.
Each list and detail route serves two representations of one URL: a whole
document for a normal navigation, and the bare fragment htmx is going to swap
when the request carries HX-Request. So /ui/orders?status=valid is a real,
bookmarkable URL whether you got there by clicking a filter or by typing it.
The CSRF token reaches the browser as an hx-headers attribute on <body> and
comes back as the same X-CSRF-Token header the API uses — the pages needed no
second CSRF mechanism, which is the main reason htmx was chosen over plain
forms.
Sign-in is the exception: a plain HTML form, no JavaScript, protected by the origin gate rather than a token (there is no session to have one yet). It works with scripting disabled.
The API
Mounted at /api, unversioned. Every response is application/json with
Cache-Control: no-store.
A filter left blank is the same as leaving it out: ?profile= is every profile,
not a profile whose name is the empty string. That matters because the panel’s
own controls are <select> elements inside a submitted form, which always send
their name — so every profile and any status reach the API as blanks.
| Method | Path | |
|---|---|---|
POST | /api/session | sign in — {username, password} |
GET | /api/session | who am I, and my csrfToken |
DELETE | /api/session[?all=true] | sign out (of this browser, or all) |
GET | /api/session/mfa | what a half-authenticated cookie still owes — {step} |
POST | /api/session/mfa | finish the sign-in — {code}, a TOTP or a recovery code |
GET | /api/mfa | {totpEnabled, enrolmentPending, recoveryCodesRemaining} |
POST | /api/mfa/totp | begin an enrolment — returns the secret, once |
POST | /api/mfa/totp/confirm | {code} — returns the recovery codes, once |
DELETE | /api/mfa/totp | turn it off; 409 while admin.require_mfa is on |
POST | /api/mfa/recovery-codes | reissue — returns them once |
GET | /api/accounts?profile=&eabKid=&limit=&offset= | eabKid: the accounts one credential registered |
GET | /api/accounts/{id} | |
GET | /api/accounts/{id}/orders?limit=&offset= | |
PATCH | /api/accounts/{id} | {contact: [...]} |
POST | /api/accounts/{id}/deactivate | |
DELETE | /api/accounts/{id} | cascades to the account’s orders; 409 live_certificates while one holds a live certificate |
GET | /api/orders?profile=&accountId=&status=&identifier=&identifierContains=&certSerial=&limit=&offset= | identifier is exact, identifierContains a substring, the two mutually exclusive; certSerial is the issued leaf’s serial |
GET | /api/orders/{id} | order + authorizations + challenges, plus certificatePem once issued |
POST | /api/orders/{id}/revoke | {reason} optional |
DELETE | /api/orders/{id} | 409 live_certificates while its certificate is live |
GET | /api/eab?limit=&offset= | never shows a secret |
POST | /api/eab | {label, profile} — returns the secret, once |
GET | /api/eab/{kid} | |
POST | /api/eab/{kid}/revoke | the row survives, moved to revoked |
DELETE | /api/eab/{kid}?accounts=keep|deactivate|delete | the row goes; 409 live_certificates for delete while an account holds a live certificate |
GET | /api/expiring?profile=&days=&superseded=&limit=&offset= | read-only; superseded=hide drops the replaced rows |
GET | /api/audit?profile=&accountId=&orderId=&certSerial=&event=&outcome=&limit=&offset= | read-only |
GET | /api/audit/{id} | one row |
GET | /api/nonces | {count, ttlSeconds}, the shape nonce count --json prints |
POST | /api/nonces/cleanup | {ttlSeconds} optional |
GET | /api/profiles | the endpoints actually mounted; profile list’s document |
GET | /api/profiles/{name}/filter | one endpoint’s resolved access policy; read-only |
GET | /health | unauthenticated, no database access |
Lists return an envelope, not a bare array — total is what the same filters
match unpaged, which is what a page control needs:
{ "items": [ … ], "total": 137, "limit": 50, "offset": 0 }
limit defaults to 50 and is clamped to admin.page_size_max rather than
refused. Every listing on the CLI answers the same four members
(Admin CLI → Paging), so a script learns one shape rather than
two.
POST /api/session for an operator with a second factor answers 200 with
{"mfaRequired": true, "step": "verify"} and no user member — a
half-authenticated session must not read operator metadata. It is not a 401:
the password was right, and a script has to be able to tell those apart. Send
the code to POST /api/session/mfa, which answers the ordinary session body and
a new cookie; the pending one is dead by then.
The two …/session/mfa routes are the only mutating endpoints that do not take
X-CSRF-Token. The sign-in page they serve is a plain form with no token to
send, exactly as POST /api/session has none, and the origin check covers both.
The issued chain
An order’s detail carries certificatePem, the chain as it was issued, and the
order page renders it with a GET /ui/orders/{id}/chain.pem download
(application/pem-certificate-chain). The ACME certificate member beside it
is a URL, and one a browser cannot follow — RFC 8555 §7.4.2 serves it by
signed POST-as-GET only, so it is there for completeness rather than as a link.
certificatePem is on the detail shape only, never on a listing: a page of
fifty orders would otherwise carry fifty chains for a column no list shows. An
order that never reached issuance has neither the field nor the download, and
the route answers 404 rather than an empty file.
On a host holding the database, acme-proxy order chain <id> prints the same
bytes on stdout under the same rule — see
Admin CLI → Order management.
The card names the leaf two more ways, and both are on the listing shape as
well since each is one short string: certSerial, which is what an abuse
report quotes and what GET /api/audit?certSerial= filters on, and
certNotAfter, the certificate’s own expiry — a different date from the Not after above it, which is the §7.4 window the client requested. Both are
omitted on an order that never issued rather than sent empty.
Revocation
POST /api/orders/{id}/revoke resolves the revocation route from the
order’s own profile. Two profiles can hold two different CAs, and revoking
against the wrong one would record nothing useful and leave the real CRL
untouched. An order belonging to a profile this process no longer mounts
answers 409 profile_not_mounted rather than guessing.
The panel holds no signing backend — only the worker role does — so it revokes
the way the CLI does. A local_ca revocation is recorded at once and the worker
signs it into the CRL. A relay or custom revocation is queued for the
worker and waited on; if it is still running when the wait ends, the API
answers 202 {"status": "queued", "job": "<id>"} and the page says so. Follow
it on the Jobs page.
The job queue
/api/jobs and /ui/jobs list the background queue and expose two mutations,
both requiring an operator-or-higher session (a viewer is refused):
POST /api/jobs/{id}/cancel— retires the job (status = cancelled). Arunningjob is refused with409 job_not_cancellable; wait out its lease and cancel the resultingready/failedrow. Cancelling asigner_relay_issuejob also marks its ACME orderinvalidand abandons the upstream mapping, with acertificate_issue_failedaudit row naming the operator — the way to stop a relayed issuance that will never complete. Cancelling a periodic sweep job stops that sweep until the server restarts.POST /api/jobs/{id}/run— makes the job eligible immediately (picked up withinjobs.poll_interval_ms; the runner is not woken). On areadyjob it pullsrun_atforward; on afailedone it grants exactly one more attempt —attemptsis set tomax_attempts - 1, not reset, so repeatedly clicking it is the only way to loop a permanently failing job.
On the page, a 409 renders as a banner beside the still-present card; a 5xx
replaces the page. The list rows carry no controls — the two buttons live on
the detail card, and only when the job is ready or failed.
/api/upstream-orders and /ui/upstream-orders are read-only, like the
audit trail below but for the queue’s reason: abandoning an in-flight relay is
POST /api/jobs/{id}/cancel, not a route here. They list one row per relayed
local order — the upstream URLs, the upstream CA’s own error text, the finalize
request’s request_id — joined to the local order for its identifiers and
status, and never carry the stored CSR. Both contribute no entry to the CSRF
test table.
The audit trail is read-only here
/api/audit and /ui/audit list, filter, page and resolve a row by id, and
that is all they do. There is no route on this listener that deletes audit
history, and that is a deliberate limit rather than a missing feature: the first
thing a stolen session would do is erase what it had just done, and a trail the
watched thing can erase proves nothing. Pruning happens on the host with
acme-proxy audit cleanup, or on a schedule via audit.retention_days.
This is also why /api/audit contributes no entry to the CSRF test table — with
no mutating verb, there is nothing to protect. Every other verb on those paths
is unroutable. See Audit Trail.
The expiry list is read-only too, for a different reason
/api/expiring and /ui/expiring answer the digest’s question on demand:
what lapses soon, and has anything replaced it? They share the query, the
ordering and the supersession rule with [notify.expiry] and with order list --expiring-in, so a page and a mail never disagree about what is about to
expire.
Neither has a mutating route, and the reason is not the audit trail’s: renewal is the client’s action, driven by its own ACME flow against a key this server does not hold. There is simply nothing here for a button to do. Both therefore contribute no entry to the CSRF test table.
Two members of the answer need reading together. total counts the rows the
window matches; hidden counts the ones this page dropped as already
replaced. They are separate because supersession is computed per row rather
than in SQL, so the count beside the page cannot follow the filter down — and a
pager whose arithmetic quietly disagrees with the rows under it would be worse
than saying so. The page says it in a line above the table.
The default window is [notify.expiry] lead_days wherever the digest is on,
and 30 days where it is off: an operator who has chosen a lead time gets that
one back.
The policy is shown, never explained
/api/profiles/{name}/filter and /ui/profiles/{name}/filter answer what
acme-proxy filter show prints: the default effect, every check with its type
and the stages it decides at, and every rule in evaluation order with its
condition re-parenthesized, so an operator who wrote a or b and c reads
a or (b and c) back. It is the missing half of the warning on /ui/profiles:
an endpoint with challenge.bypass on has [filter] and nothing else between
it and its clients, and this is where that policy can be read.
filter explain has no equivalent here and is not getting one. It really
runs the policy — it executes the operator’s custom scripts and issues real
IPAM and DNS requests, against an address and a list of names the caller
chose. Behind a session that is script execution plus SSRF from one stolen
cookie, on a listener that deliberately carries no filter chain and no
admission control. show is the opposite case: it reads an already-built
policy through four accessors, runs no check, and reaches nothing outside the
process. Neither surface has a mutating verb — a policy is configuration, and
configuration is edited in config.toml and reloaded — so both contribute no
entry to the CSRF test table, for the same structural reason /api/audit does
not.
One difference from the CLI is worth knowing, because it is the useful kind.
The panel reads the live policy, the one this process is enforcing right
now; acme-proxy filter show rebuilds one from configuration, which is what
makes it the cheapest pre-restart check. [filter] reloads on SIGHUP and the
whole policy is swapped, so between an edit and its reload the two legitimately
disagree — and a configuration that would be refused is reported by the CLI
while the panel goes on serving the last good policy. Both answers are correct;
comparing them is how an operator finds out which state they are in.
An endpoint with no rules is a state, not an error: the answer carries
"active": false and says the endpoint filters nothing, rather than a 404.
That is the one policy an operator most needs to be told about.
Security notifications
Every web-admin sign-in and every second-factor change is logged, and — when
[admin.notify] is configured — the operator it happened to is also told
(ASVS V6.3.5 / V6.3.7). Two events fire:
admin_sign_in— a completed sign-in from an address not among the operator’s recent ones (admin_users.known_login_ipskeeps the last five distinct addresses; a first-ever sign-in has no baseline and is silent), a correct password followed by a refused second factor, or a per-session second-factor lockout.admin_credential_changed— the operator’s password changed, a second factor was enrolled or removed, recovery codes were regenerated, or another administrator reset this operator’s second factor (by_self = false). Also when the operator’s notification address changed (change = "contact_address") — and that one message goes to the address that was replaced, not the new one, since whoever made the change controls the new address. A first-ever address replaced nothing and tells nobody.
[admin.notify] has exactly the shape of the per-profile [notify]
section — enabled, email / webhook / custom
backends, template_dir, per-backend events — but is process-wide and built
only while [admin] is enabled (ACME_PROXY_ADMIN__NOTIFY__ENABLED). Email
delivery goes to each operator’s own address rather than to notify.email.to,
stored in admin_users.contact_email. An operator sets their own from Your
account (it asks for the current password), an admin sets a colleague’s
from the Operators page, and the host sets anybody’s with
acme-proxy admin user create --contact <address> or
admin user contact <username> --contact <address>. See
Users → Notification address.
An operator with no address on file gets no message (the event is still
logged); [admin.notify.email].to, if set, is the fallback for that case.
Changes made from the host CLI (admin user passwd, contact,
totp reset, totp recovery-codes) notify too. The CLI queues the delivery
and exits, and the running server’s job runner sends it, so a change made
while no server runs is delivered when one starts. Nothing is queued while
admin.enabled is off, since there is then no [admin.notify] to deliver
through.
Errors
Not ACME problem documents. Every Problem type in this server is a hardcoded
urn:ietf:params:acme:error:* URN, and nothing on this listener is an ACME
error:
{ "error": "not_found", "message": "no such account: acct-1" }
error is a stable snake_case code you may branch on; message is for a human
and may change. The codes:
- Request:
bad_request,invalid_status,invalid_contact,conflicting_identifier_filter,not_found,method_not_allowed. - Session and access:
session_invalid,session_expired,session_idle,invalid_credentials,csrf_failed,rate_limited,access_denied,insufficient_role,mfa_required,mfa_not_enabled. - The row’s state:
order_not_issued,already_revoked,live_certificates,last_admin,job_not_cancellable,job_not_runnable,profile_not_mounted. - Server:
signer_failed,internal.
The pages answer the same failures as HTML carrying the same code, with one
deliberate split: a refusal that is about the row’s state — 409 already_revoked, order_not_issued — comes back as a banner beside the button
you pressed, with the record still on screen, while a server problem
replaces the page. A missing session is neither: a browser gets 303 to
/ui/login, and an htmx request gets the HX-Redirect header, because a 303
is followed by fetch before htmx ever sees it and the sign-in page would be
swapped into whatever you clicked.
Driving it with curl
$ curl -sc jar -X POST http://127.0.0.1:3001/api/session \
-H 'content-type: application/json' \
-d '{"username":"alice","password":"…"}'
{"csrfToken":"…","expiresAt":"…","user":{…}}
$ curl -sb jar 'http://127.0.0.1:3001/api/orders?limit=5'
$ curl -sb jar -X POST http://127.0.0.1:3001/api/eab \
-H "x-csrf-token: $CSRF" -H 'content-type: application/json' \
-d '{"label":"team-a"}'
Security notes
- This is a second attack surface on a certificate authority. It is off by
default, binds loopback, and needs a session — but it has no admission
control, and filters nothing until
[admin.filter]names a rule. That is worth knowing rather than discovering. - Sign-in is protected by a fixed-window rate limiter
(
admin.login_max_attemptsperadmin.login_window_seconds, keyed on the client address — an IPv6 client by its /64, which one subscriber can rotate through at will). An attempt counts from the moment it starts, so a parallel burst gets no more guesses than a sequence. Over the limit, the password hash is not computed at all — 600 000 iterations is a denial-of-service lever otherwise — and the hash that does run is on the blocking pool, off the workers that serve requests. - A forwarded-for header is believed only from
admin.filter.trusted_proxies, never from the ACME listener’sfilter.trusted_proxies; honouring it from anyone else would let a caller spoof the key the rate limiter counts on. Behind a reverse proxy that is not listed there, the limiter counts the proxy. - Every response carries
Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'. Nounsafe-inline, nounsafe-eval— affordable because htmx is served from this origin and drives everything throughhx-*attributes rather than inline handlers. Alongside it:no-store,nosniff,X-Frame-Options: DENY,Referrer-Policy: same-originand HSTS. htmx.min.jsis a vendored third-party file, andcargo denyaudits the crate graph and cannot see it. Its version, source URL, SHA-256 and licence are recorded incrates/admin/src/webadmin/static/README.md, which is the only provenance record there is — check it when you update.- A second factor (TOTP) is available per operator, and
admin.require_mfamakes it compulsory — see Operators and sessions. It is off by default, so the loopback bind plus an SSH tunnel remains the baseline posture and not a substitute for one. - WebAuthn is not implemented.
webauthn-rs0.5 hard-depends onopenssl/openssl-sysand is MPL-2.0, neither of which this tree carries; the design does not preclude it later (another factor is another branch in the same state machine, not a change to it).
Configuration
See the Configuration Reference for every
[admin], [admin.filter] and [admin.tls] key, and Customizing the
Panel for admin.template_dir.
[htmx]: https://htmx.org [minijinja]: https://docs.rs/minijinja