Skip to content

ACME (RFC 8555): automated certificate issuance

secsy-pki includes an ACME server so standard ACME clients — certbot, lego, acme.sh, Caddy, Traefik, cert-manager, and the Go golang.org/x/crypto/acme client — can obtain certificates automatically. Every certificate the ACME server issues is signed by an HSM-backed CA through the same ca.Manager used by the REST API and CLI, and every order is written to the tamper-evident audit log.

The ACME endpoints authenticate clients with their own account key pairs (JWS-signed requests, per RFC 8555 §6.2) rather than OIDC or the root user, so they are mounted outside the OIDC auth middleware. Access is governed instead by three deployment controls, described below: a fixed issuing-CA with one or more operator-vetted profiles, optional External Account Binding, and per-order auditing.

1. Enabling the ACME server

ACME is off by default. Enable it in config.yaml and point it at an existing X.509 issuing CA (create one first — see the CA guide):

acme:
  enabled: true
  # Externally reachable origin. Leave empty to derive it per request from the
  # Host header + scheme (honoring X-Forwarded-Proto / X-Forwarded-Host behind a
  # TLS-terminating proxy). Set it explicitly in production.
  base_url: "https://pki.example.com"
  # The CA that signs ACME certificates. Use ca_label OR ca_id (id wins).
  ca_label: "Secsy Issuing CA"
  # Certificate profile applied to every ACME-issued certificate.
  profile: "server"
  # Advertised in the directory; when set, clients must agree on registration.
  terms_of_service: "https://pki.example.com/tos"
  # Challenge types offered per authorization (default: all three).
  challenge_types: ["http-01", "dns-01", "tls-alpn-01"]

The server fails to start if acme.enabled is true but the referenced CA does not exist or is not an X.509 issuer, so misconfiguration surfaces immediately.

Once running, the directory is served at <base_url>/acme/directory:

$ curl -s https://pki.example.com/acme/directory | jq
{
  "newNonce":   "https://pki.example.com/acme/new-nonce",
  "newAccount": "https://pki.example.com/acme/new-account",
  "newOrder":   "https://pki.example.com/acme/new-order",
  "revokeCert": "https://pki.example.com/acme/revoke-cert",
  "keyChange":  "https://pki.example.com/acme/key-change",
  "renewalInfo": "https://pki.example.com/acme/renewal-info",
  "meta": { "termsOfService": "https://pki.example.com/tos" }
}

(When the ACME Profiles extension is configured, meta additionally carries a profiles map of the selectable issuance profiles.)

2. Configuring the ACME-enabled profile

An "ACME-enabled profile" is simply the certificate profile named in acme.profile. The profile fixes the key usages, extended key usages, and validity of every ACME-issued certificate; ACME clients cannot influence these (they only supply identifiers and a CSR public key). This is deliberate: it keeps automated issuance inside a shape the operator has vetted.

Use a built-in profile (server is the default and correct choice for TLS) or define a custom one and reference it. Custom profiles are declared in the same profiles: block used elsewhere:

profiles:
  - name: "acme-tls"
    description: "Short-lived TLS server certs for ACME automation"
    key_usages:     ["digitalSignature", "keyEncipherment"]
    ext_key_usages: ["serverAuth"]
    default_validity_days: 90     # short-lived; renew often via ACME
    max_validity_days:     90

acme:
  enabled: true
  ca_label: "Secsy Issuing CA"
  profile: "acme-tls"            # <- the ACME-enabled profile

Notes:

  • The default_validity_days of the profile determines ACME cert lifetime; ACME clients renew automatically well before expiry, so short-lived certs (30–90 days) are the norm.
  • Validity is still clamped to the profile maximum and the issuing CA's own notAfter, exactly as for API/CLI issuance.
  • The global policy.max_cert_validity_days does not apply to ACME (that cap governs the interactive /issue and /sign endpoints); bound ACME lifetime through the profile instead.

Client-selectable profiles (RFC 9773)

The single acme.profile above fixes the shape of every ACME certificate. To offer several profiles from one ACME endpoint — for example a short-lived TLS-server profile and an mTLS-client profile — enable the ACME Profiles extension (RFC 9773) by mapping each ACME-visible profile name to an internal issuance profile:

acme:
  enabled: true
  ca_label: "Secsy Issuing CA"
  profile: "server"            # default when a client selects nothing (backward compatible)
  profiles:
    short-lived:
      description: "90-day TLS server certificates"
      profile: "acme-tls"      # internal (built-in or custom) profile id
    mtls-client:
      description: "Client-authentication certificates"
      profile: "client"

Each mapped internal profile is validated at startup (a typo fails fast, exactly like a bad CA reference), and the set is advertised in the directory's meta.profiles:

$ curl -s https://pki.example.com/acme/directory | jq '.meta.profiles'
{
  "short-lived": "90-day TLS server certificates",
  "mtls-client": "Client-authentication certificates"
}

A client picks one by naming it in the profile field of its newOrder request. The selection is recorded on the order and governs the whole issuance path at finalize — the pre-issuance lint, CAA, name-constraints, certificate-policy, and CT gates all run against the chosen profile. Omitting the field uses acme.profile, so clients that predate the extension keep working unchanged.

Naming a profile the server does not advertise is rejected with the urn:ietf:params:acme:error:invalidProfile problem (HTTP 400), whose detail lists the available names.

  • Client support. Profile selection is a recent ACME extension; use a client that implements RFC 9773 (recent certbot exposes --preferred-profile / --required-profile). Any client that does not send a profile field transparently receives the default profile, so enabling the extension never breaks existing automation.
  • Selected profile is visible on the operator inventory endpoint GET /api/acme/orders (the profile field) and in the acme.order.new audit event, and issuance is metered per profile — see §7 Auditing and operator visibility.

3. Challenge types (http-01, dns-01, tls-alpn-01)

For each identifier in an order the server creates an authorization offering the configured challenge types. The server performs the validation itself (outbound), so it must be able to reach the client's challenge responder. The challenge path performs no HSM operation — only the finalize step signs, on the HSM, through the shared CA manager.

http-01 (RFC 8555 §8.3) — the client serves the key authorization at http://<domain>/.well-known/acme-challenge/<token> on port 80. The server fetches it and compares. For local testing where port 80 is unavailable, set acme.http01_port to a high port (integration tests use this).

dns-01 (RFC 8555 §8.4) — the client publishes a TXT record at _acme-challenge.<domain> whose value is base64url(SHA-256(keyAuthorization)). The server resolves it via DNS. dns-01 is the only challenge type offered for wildcard (*.example.com) identifiers, per the RFC.

tls-alpn-01 (RFC 8737) — the client answers on port 443 by presenting a special validation certificate over a TLS handshake that negotiates the acme-tls/1 ALPN protocol. The server dials the identifier, offering only acme-tls/1, and requires the peer to present a self-signed certificate whose single subjectAltName is the identifier and that carries the critical id-pe-acmeIdentifier extension (OID 1.3.6.1.5.5.7.1.31) whose OCTET-STRING value equals SHA-256(keyAuthorization). This validates over the same port a web service already listens on (no port 80 responder needed), and — like http-01 — it works for IP-address identifiers (RFC 8738) but not for wildcards. For local testing where port 443 is unavailable, set acme.tls_alpn01_port to a high port.

Restrict the offered set with acme.challenge_types (e.g. ["dns-01"] to force DNS validation only, or ["tls-alpn-01"] for a port-443-only responder). The default offers all three. IP-address identifiers (RFC 8738) are disabled unless acme.allow_ip_identifiers: true; when enabled they may be validated with http-01 or tls-alpn-01.

email-reply-00 (RFC 8823) validates email-type identifiers for S/MIME issuance: the server mails a signed challenge to the mailbox and validates the reply. It is offered only when an inbound-mail (IMAP) poller is configured; see enrollment.md §7 for the acme.email block and full flow.

Multi-perspective corroboration (MPIC / SC-067)

By default the three domain-control challenges are validated from a single network vantage point, which a localized BGP/DNS hijack can fool. Enable Multi-Perspective Issuance Corroboration (CA/Browser Forum ballot SC-067) under acme.mpic to re-check each http-01 / dns-01 / tls-alpn-01 validation from several independent remote perspectives (each with its own resolver and/or outbound SOCKS5 proxy) and issue only when a quorum agrees. It is off by default and fails closed when too few perspectives corroborate. See ACME MPIC (SC-067) for the quorum rule, configuration, and observability.

4. Access control: External Account Binding

Because ACME clients self-register with any key pair, an open ACME server will issue to anyone who can satisfy a challenge for a name. To restrict who may register — the ACME analogue of an RBAC grant — enable External Account Binding (EAB, RFC 8555 §7.3.4). Each operator-provisioned key id (kid) maps to a shared HMAC secret; a client must present a valid EAB (signed with that secret) on account creation.

acme:
  enabled: true
  ca_label: "Secsy Issuing CA"
  require_eab: true
  eab_hmac_keys:
    # kid: HMAC key, base64url or standard base64 (generate: openssl rand -base64 32)
    "team-web":     "R29vZCBsdWNrIGRlY29kaW5nIHRoaXMga2V5IQ"
    "team-payments": "c2Vjc3ktcGtpLWVhYi1obWFjLWtleS1zYW1wbGU"

Provide the kid and HMAC key to the client (e.g. certbot --eab-kid team-web --eab-hmac-key <key>). Startup fails if require_eab is set with no keys.

When EAB is required the directory advertises "externalAccountRequired": true and the bound kid is recorded on the account and in the audit log.

5. Client examples

The examples below assume ACME is enabled at https://pki.example.com.

certbot (http-01)

certbot certonly \
  --server https://pki.example.com/acme/directory \
  --standalone \
  -d app.example.com \
  --agree-tos -m ops@example.com

lego (dns-01, e.g. via a DNS provider plugin)

lego --server https://pki.example.com/acme/directory \
     --email ops@example.com \
     --dns <provider> \
     --domains "*.example.com" \
     run

With External Account Binding

certbot register \
  --server https://pki.example.com/acme/directory \
  --eab-kid team-web \
  --eab-hmac-key R29vZCBsdWNrIGRlY29kaW5nIHRoaXMga2V5IQ \
  --agree-tos -m ops@example.com

Go client (golang.org/x/crypto/acme)

This is the client used by the integration test (server/internal/e2e/acme_test.go), which drives a full order end-to-end against a SoftHSM-backed CA. See that file for a complete worked example of Register → AuthorizeOrder → Accept → WaitOrder → CreateOrderCert.

6. Account & authorization lifecycle

Beyond registration and ordering, the server implements the account- and authorization-management operations of RFC 8555.

Contact validation (§7.3)

An account's contact array — supplied on newAccount or an account update — is validated, not stored blindly. The server supports only mailto: contacts, and each, following RFC 8555 §7.3 and the mailto grammar of RFC 6068, must be a single address carrying no header fields (the ?subject=… "hfields"):

Contact Result
mailto:admin@example.com accepted
tel:+1-555-0100, https://hook.example urn:ietf:params:acme:error:unsupportedContact (400)
mailto:a@example.com?subject=hi urn:ietf:params:acme:error:invalidContact (400) — header fields
mailto:a@example.com,b@example.com invalidContact (400) — more than one address
mailto:not-an-address invalidContact (400) — malformed address

An unsupported scheme (anything but mailto:) is rejected with unsupportedContact; a supported mailto: contact with an invalid value is rejected with invalidContact, exactly the two error types RFC 8555 §7.3 prescribes. An empty or omitted contact array is valid (the account simply has no contacts); a contact update replaces the whole set.

Account deactivation and key rollover (§7.3)

A client deactivates its account by POSTing {"status": "deactivated"} to the account URL; a deactivated account can no longer act (subsequent requests are unauthorized). It rotates its account key through the key-change endpoint (RFC 8555 §7.3.5), proving control of both the old and the new key in one signed request.

Authorization deactivation (§7.5.2)

A client relinquishes an authorization — for example to give up the ability to issue for an identifier it no longer controls — by POSTing {"status": "deactivated"} to the authorization URL (RFC 8555 §7.5.2). The response is the updated authorization object:

{ "status": "deactivated",
  "identifier": { "type": "dns", "value": "app.example.com" },
  "challenges": [ ... ] }
  • Only the owning account may deactivate an authorization (otherwise unauthorized), and only a live authorization — pending or valid — can be deactivated. Deactivating an already-deactivated one is idempotent (200); deactivating one that has already resolved terminally (invalid/expired/revoked) is rejected as malformed.
  • On success the authorization moves to deactivated, its still-open challenges are closed out, and any pending order that depended on it becomes invalid — it can never be fulfilled now. An order that had already been finalized keeps its issued certificate.
  • A deactivated authorization is never reused: a later order for the same identifier validates afresh (a deactivated pre-authorization, RFC 8555 §7.4.1, is likewise not claimed).
  • The operation is audited as acme.authz.deactivate and metered by secsy_acme_authz_deactivations_total{result} (deactivated|rejected).

7. Auditing and operator visibility

Every ACME operation appends an entry to the hash-chained event log:

Action When
acme.account.new An account is registered (records the EAB kid, if any)
acme.order.new An order is placed (records the identifiers and the selected issuance profile)
acme.challenge A challenge is validated or fails (the detail records the challenge type — http-01, dns-01, or tls-alpn-01 — and identifier)
acme.order.finalize A certificate is issued (records the serial and profile)
acme.authz.deactivate An authorization is deactivated by its account (RFC 8555 §7.5.2)
acme.cert.revoke A certificate is revoked via ACME
acme.renewal_info A renewal-info (ARI) window is served (records whether the window is normal/revoked/rotating)
acme.order.replaces A new order links to the certificate it renews via replaces

The actor is acme:<account-id>. These events are covered by the same tamper-evidence and GET /api/events/verify integrity check as the rest of the log, and ACME-issued certificates appear in the CA's issued_certificates inventory and its CRL/OCSP responses just like any other certificate.

Challenge validation is also metered: secsy_acme_challenge_validations_total counts attempts by challenge type (http-01|dns-01|tls-alpn-01) and result (valid|invalid), giving each challenge type observable parity on the metrics endpoint. Issuance is metered per profile: secsy_acme_certificates_issued_total{profile} counts certificates issued through finalize by the internal issuance profile they were signed under, so with the ACME Profiles extension an operator can see volume broken down by selectable profile. Authorization deactivations (RFC 8555 §7.5.2) are metered by secsy_acme_authz_deactivations_total{result} (deactivated|rejected), surfacing clients decommissioning identifiers.

Operators can inspect ACME state through RBAC-gated (read: admin/issuer/auditor) inventory endpoints:

GET /api/acme/accounts    # registered ACME accounts
GET /api/acme/orders      # ACME orders and their status/serials

8. Endpoint reference

All paths are relative to acme.directory_path (default /acme). Except the directory and new-nonce, every endpoint is a JWS-signed POST (POST-as-GET for reads), per RFC 8555.

Method Path Purpose
GET /acme/directory Service directory
HEAD/GET /acme/new-nonce Fetch an anti-replay nonce
POST /acme/new-account Register / look up an account
POST /acme/new-order Create an order
POST /acme/order/{id} Fetch an order (POST-as-GET), or cancel a STAR recurrence (status="canceled")
POST /acme/order/{id}/finalize Submit a CSR to issue
POST/GET /acme/star-cert/{id} Download the current STAR certificate (GET is unauthenticated when the order set allow-certificate-get)
POST /acme/authz/{id} Fetch an authorization (POST-as-GET), or deactivate it (status="deactivated", RFC 8555 §7.5.2)
POST /acme/chall/{id} Respond to / fetch a challenge
POST /acme/cert/{id} Download the issued chain (PEM)
POST /acme/acct/{id} Fetch / update / deactivate an account
POST /acme/acct/{id}/orders List an account's orders
POST /acme/revoke-cert Revoke a certificate
POST /acme/key-change Rotate the account key
GET /acme/renewal-info/{certID} Renewal Information (ARI) — suggested renewal window

9. Renewal Information (ARI)

The server implements ACME Renewal Information (draft-ietf-acme-ari) so clients schedule renewals against the server's advice instead of a fixed timer, and react promptly when a certificate must be replaced.

Advertisement. The directory carries a renewalInfo field (the base URL of the resource). ARI-aware clients (recent certbot, lego, Caddy, …) pick it up automatically.

Looking up a window. A client requests GET /acme/renewal-info/<certID>, where certID is base64url(AuthorityKeyIdentifier) || "." || base64url(SerialNumber) (ARI §4.1). The response is an unauthenticated JSON body plus a Retry-After header:

$ curl -s https://pki.example.com/acme/renewal-info/aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE
{
  "suggestedWindow": {
    "start": "2026-09-01T00:00:00Z",
    "end":   "2026-09-16T00:00:00Z"
  },
  "explanationURL": "https://pki.example.com/notices/mass-renewal"
}

The client picks a uniformly random time within [start, end) and renews then, which spreads renewal load across the fleet.

Window policy.

  • Normal: the window begins acme.renewal_window_days before expiry (falling back to the expiry monitor's renew_before_days, then to the final third of the certificate's lifetime) and spans acme.renewal_window_width_hours (default: half the renew-before span).
  • Forced (immediate): when the certificate has been revoked, or its issuing CA key is being rotated (the key is superseded mid-rollover), the window ends at now so the client renews right away and migrates onto the new key.

Renewal linkage (replaces). A newOrder request may carry a replaces field naming the CertID of the certificate it renews. The server verifies the predecessor was issued to the same account and has not already been replaced (returning urn:ietf:params:acme:error:alreadyReplaced otherwise), records the linkage on the order, and audits it (acme.order.replaces).

Configuration.

acme:
  renewal_window_days: 30          # when the suggested window opens (0 = derive)
  renewal_window_width_hours: 360  # window width (0 = half the renew-before span)
  renewal_poll_hours: 6            # advertised Retry-After cadence
  renewal_explanation_url: "https://pki.example.com/notices/mass-renewal"

To compute a CertID from a certificate you hold, use acme.CertID(*x509.Certificate).

10. STAR: short-term auto-renewed certificates (RFC 8739)

STAR turns an order into a subscription: the server issues a short-lived certificate and automatically re-issues it ahead of expiry, and the subscriber always fetches the current one from a single stable URL. It suits deployments that prefer very short lifetimes (so revocation is rarely needed) with no per-renewal client work — and it lets a relying party fetch the certificate over an unauthenticated GET, e.g. for CDN push. Off by default.

Enabling.

acme:
  star:
    enabled: true
    min_lifetime_hours: 1     # floor on each certificate's lifetime (advertised as min-lifetime)
    max_lifetime_hours: 168   # ceiling on each certificate's lifetime (7 days)
    max_duration_days: 365    # longest total recurrence (advertised as max-duration)

When enabled the directory advertises the bounds under meta.auto-renewal:

$ curl -s https://pki.example.com/acme/directory | jq '.meta."auto-renewal"'
{ "min-lifetime": 3600, "max-duration": 31536000, "allow-certificate-get": true }

Ordering. A client adds an auto-renewal object to its newOrder (only dns/ip identifiers — not S/MIME email):

{
  "identifiers": [{ "type": "dns", "value": "app.example.com" }],
  "auto-renewal": {
    "start-date": "2026-07-05T00:00:00Z",   // optional; defaults to now
    "end-date":   "2026-10-05T00:00:00Z",   // required — recurrence horizon
    "lifetime":   86400,                      // required — per-certificate seconds
    "allow-certificate-get": true             // optional — permit unauthenticated GET
  }
}

The recurrence is validated against the configured bounds (lifetime within min/max, duration within max, end-date in the future); a violation is rejected with urn:ietf:params:acme:error:malformed. The order is then validated and finalized exactly like a normal order — you still solve a challenge per identifier and submit a CSR — after which it reports status: valid and carries a star-certificate URL instead of certificate, plus the resolved auto-renewal object. expires reflects the recurrence's end-date.

Fetching. star-certificate always returns the current certificate:

# Authenticated POST-as-GET (always available):
# ... signed JWS POST to the star-certificate URL ...
# Unauthenticated GET (only when the order set allow-certificate-get):
$ curl -s https://pki.example.com/acme/star-cert/<order-id>
-----BEGIN CERTIFICATE----- ...

Renewal. A leader-elected background job re-issues each STAR certificate before it expires (from the CSR captured at finalize — same key and identifiers), up to end-date, then stops. Because the certificates are deliberately short-lived and self-renewing, they are excluded from the expiry monitor. Renewal never involves the client.

Cancellation. POST status: "canceled" to the order URL (RFC 8739 §3.5) to end the subscription: renewal stops immediately and star-certificate then answers 403 with urn:ietf:params:acme:error:autoRenewalCanceled.

Observability. secsy_acme_star_orders_total{event} counts created|renewed|renew_failed|canceled|ended, and each event is audited under the cert.acme action.

Client support. STAR is a niche extension; certbot/lego do not implement it. Drive it with a STAR-aware client or a direct JWS integration. The server's behavior is exercised by the raw-JWS test in server/internal/acme/star_test.go.

11. Operational notes

  • TLS. Real ACME clients require the directory to be served over HTTPS. Configure server.tls_cert/tls_key, or terminate TLS at a trusted proxy and set base_url/forwarded headers accordingly. (The server itself already fails closed without TLS unless explicitly overridden.)
  • Reachability. For http-01 the server must reach the client on port 80, for tls-alpn-01 on port 443 (negotiating acme-tls/1), and for dns-01 it must resolve public DNS. In split-horizon networks, prefer dns-01.
  • Nonces are single-use anti-replay tokens (RFC 8555 §6.5) and are correct across replicas. Each is self-authenticating — an HMAC over a timestamp and random bytes, keyed by a server-wide secret shared through the store — so a nonce minted by one replica is accepted by any other behind a load balancer (no more spurious badNonce on a round-robin request). Single use is enforced by a shared consumed-set in the store, so a replay is rejected everywhere. Expiry (30 min TTL) and forged/malformed nonces are rejected in-process before the store is touched, keeping the fast path cheap; a leader-elected sweep prunes the consumed-set. No configuration is needed — the shared secret is generated once and persisted automatically. Operators who prefer to pin or rotate the key may set acme.nonce_hmac_key (base64, ≥16 bytes) identically on every replica. The secsy_acme_nonces_total{result} metric breaks nonce outcomes down by issued|valid|replayed|expired|invalid|error.

    Known follow-up: rate-limit token buckets remain per-replica (each replica meters independently), so effective public-endpoint limits scale with the replica count. Only the anti-replay nonce store is shared today; see Rate limiting and High availability.

  • Revocation is authorized either by the account that placed the order or by the certificate's own key pair, and flows through the standard revocation store → it appears in the CA's CRL and OCSP responses.
  • Testing. server/internal/e2e/acme_test.go (build tag sqlite, gated on the SECSY_* SoftHSM env) runs the full http-01 and dns-01 flows, revocation, and the ARI renewal-hint flow against a real token; server/internal/acme/server_test.go runs the http-01, dns-01, and tls-alpn-01 flows against the software provider (no HSM needed), and server/internal/acme/tlsalpn_test.go covers the RFC 8737 validation logic hermetically with an in-process TLS responder presenting correctly and incorrectly crafted validation certificates.

See also