Audit Trail
A certificate authority’s most important record is not what it holds but what it
did: who asked it to sign or withdraw a certificate, who administered it, from
where, and what it answered. acme-proxy writes that down in one append-only
table and surfaces it in three places — the CLI, the JSON API and the panel.
There is deliberately no audit.enabled. Recording who asked the CA to sign
something, and who changed its stored state, is not a feature of this server, it
is what a CA does. The only thing an operator can switch off is the reverse-DNS
lookup, because that one costs a network round trip and there are estates where
it can never succeed.
What gets recorded
Two different things, in two different places.
Traceability columns, on the rows themselves:
| Table | Columns |
|---|---|
accounts | created_ip / created_ptr — where newAccount was called from, frozen at creation; last_seen_at / last_seen_ip / last_seen_ptr — where the key last authenticated a request |
orders | created_ip / created_ptr — where the order was opened from |
The audit log, one row per CA action and per refusal, plus one per successful administrative action:
| Event | Written when |
|---|---|
certificate_issued | the signer returned a certificate |
certificate_issue_failed | finalize was refused after the order was ready — a bad CSR, a CSR/order mismatch, a filter denial, or the backend’s own failure |
certificate_revoked | a revocation succeeded, whether through ACME, the CLI or the panel |
certificate_revoke_failed | a revocation was refused — including alreadyRevoked, an unauthorized caller, and an unknown certificate |
The refusals are the point. A stream of certificate_revoke_failed rows naming
certificates that do not exist is somebody enumerating serials, which is exactly
the question a trail exists to answer.
Administrative actions
An operator (through the panel) or the host CLI changing stored state also
leaves a row. These are recorded only on success — a not-found or refused
admin operation is the operator being told the state of things, not the CA
turning a remote party away — and each is attributed to actor_kind = "admin"
(the operator’s username and resolved address) or "cli" (the host).
| Event | Written when |
|---|---|
account_deactivated / account_contact_updated / account_deleted | an ACME account was deactivated, had its contact list rewritten, or was hard-deleted (the row names the account and its profile; account_deleted carries the cascade count) |
order_deleted | an order row was hard-deleted (names the order, account and identifiers) |
eab_created / eab_revoked / eab_deleted | an External Account Binding credential was minted, revoked or deleted (the detail names the kid; the secret is never recorded). eab_deleted also says whether its accounts were kept, deactivated or deleted, and each account changed gets its own account_deactivated or account_deleted row |
operator_created / operator_deleted | a web-admin operator was added or removed |
operator_role_changed / operator_disabled / operator_enabled | an operator’s privilege tier or sign-in status changed |
operator_password_changed / operator_contact_updated | an operator’s password or notification address changed (self-service or by an admin) |
operator_totp_enrolled / operator_totp_disabled / operator_recovery_codes_regenerated | an operator’s second factor was enrolled, removed (including an admin reset), or its recovery codes reissued |
session_revoked | one session, all of an operator’s sessions, or every session on the server was revoked (the detail says which) |
job_cancelled / job_advanced | a background job was cancelled or nudged/revived (run-now). A cancelled relay issuance instead writes certificate_issue_failed — it abandons a certificate order |
nonce_cleanup_completed / audit_pruned | the nonce table or the audit log itself was swept by hand (audit_pruned records its own action, so a manual prune always leaves the one row that says it happened) |
database_transferred | every row was copied into another backend (acme-proxy transfer). Written to the source, which is the database that holds the trail leading up to the move; the copy has already read past this row, so the target’s own trail begins at the transfer it arrived in |
The vocabulary is defined by acme_proxy_core::audit::AuditEvent, not by a
database constraint, so a newer server writing a name an older one does not know
still loads on the older one — it simply shows the raw string. The web audit
surface stays read-only: a stolen session that could erase the trail would
make the trail prove nothing, so pruning is audit cleanup on the host or
audit.retention_days and nothing else.
Two boundaries worth knowing:
- Nothing is ever compared against any of this. Pinning an identity to an address breaks CGNAT and mobile clients, so these columns answer “who asked for this certificate, and from where”, never “may this request proceed”.
- None of it reaches an ACME object. The wire format is RFC 8555’s and stays that way. The trail is visible through the admin surfaces only.
What is not recorded
Refusals that never reached a CA action. A request rejected for a bad signature,
a replayed nonce or an unready order is protocol bookkeeping — nothing was
signed, nothing was withdrawn, and recording it would bury the rows that matter.
For the same reason, a revokeCert payload that cannot be parsed at all writes
nothing: a row naming no subject is noise.
Reading it from the CLI
acme-proxy audit list
acme-proxy audit show 4213
| Command | Flags |
|---|---|
audit list | --profile, --account-id, --order-id, --cert-serial, --event, --outcome, --since-days <n>, --limit <n>, --offset <n>, --json |
audit show <id> | --json |
audit cleanup | --older-than <days> (prompts) |
audit list is paged, defaulting to 50 rows, as account list and order list are. This table grows a row per issuance for the life of the deployment,
so it always prints N of M row(s) — a page must never be mistaken for the
whole trail. There is no “everything” spelling on purpose; on a year-old CA that
is a terminal full of scrollback and a table loaded into memory. Page with
--offset, and see Paging for the window every listing shares
and the --json envelope it answers with.
$ acme-proxy audit list --outcome failure --since-days 7
4213 2026-08-09T18:22:04Z certificate_issue_failed prod acme:acct-9f2c 10.4.1.19 (web7.corp.example) api.corp.example reason=badCSR
1 of 1 row(s).
An unknown --event or --outcome is refused by name, listing the values
this build knows. Passed through to SQL it would answer “no rows”, which looks
exactly like “nothing happened” — the single most misleading answer an audit
tool can give.
audit show prints one field per line, omitting every field that was not
recorded rather than showing it as empty:
$ acme-proxy audit show 4213
id 4213
created 2026-08-09T18:22:04Z
event certificate_issue_failed
outcome failure
profile prod
actor acme:acct-9f2c
account acct-9f2c
order ord-71ab
client_ip 10.4.1.19
client_ptr web7.corp.example
user_agent certbot/2.9.0
request_id 01J9F2K7Q4
reason badCSR
identifiers api.corp.example
The actor
Every row names who acted, as a kind plus an optional id:
| Kind | Meaning |
|---|---|
acme | an ACME client, identified by its account id |
admin | a web admin operator, identified by username |
cli | someone on the host — there is no request and no address, and the row says so |
system | the server itself, e.g. a relayed order settling in the background |
An administrative revocation is attributed to the operator, not the certificate’s owner. Recording the client there would say the opposite of what happened.
Retention
audit.retention_days = 0 — the default — keeps everything for ever, which is
the right default for a trail whose value is that it is complete. Setting it
non-zero spawns a daily sweep running the identical DELETE as:
acme-proxy audit cleanup --older-than 365
This is the only command in the binary that destroys audit history, so it is
confirm-gated and its prompt names the number of rows it is about to remove. Use
-y to skip the prompt in a cron job.
The web admin cannot prune the trail.
/api/auditand/ui/auditare read-only, and there is no route to list, because the first thing a stolen session would do is erase what it had done — and a trail that can be erased by the thing it is watching proves nothing. Pruning happens on the host, or on a schedule set in configuration.
Reading it from the web admin
| Surface | |
|---|---|
GET /api/audit?profile=&accountId=&orderId=&certSerial=&event=&outcome=&limit=&offset= | the paged envelope every list endpoint returns |
GET /api/audit/{id} | one row |
/ui/audit | the list, filterable, with a detail page per row |
The API omits absent fields rather than sending them as null, so a client can
test for presence directly. There is no since filter on this surface: a
browser filters by picking a page, and a date parser here would be a second
definition of “how far back” that the CLI already has.
Rows carry remote text. A
User-Agentand a reverse DNS name are both written by whoever is on the other end — the PTR for a client’s address is controlled by whoever runs that address’s reverse zone. The panel escapes them like any other untrusted value, andtests/admin_pages.rspins it.
Why it survives deletion
audit_log is the one table in the schema with no foreign keys, and that is
the design rather than an oversight. An audit row has to outlive account delete and order delete; a CASCADE would destroy the evidence along with
its subject. So account_id and order_id are plain columns naming a row that
may be gone, and the identifiers are frozen into the row rather than joined back
to an order that no longer exists.
The rest follows from the same rule: rows are only ever inserted — there is no
setter and no UPDATE anywhere in the crate — and the primary key is
AUTOINCREMENT, so SQLite cannot reuse the rowid of a purged row.
Configuration
See [audit] for the three keys. The
section is process-wide rather than per-profile: the trail describes the CA, not
one of its endpoints.