Secrets, PKI & CertificatesX · ACME and Certificate AutomationAutomation
The ACME protocol — accounts, orders, authorizations and the two keys
What you'll learn
- Trace an ACME issuance through the order, authorization and challenge state machines
- Distinguish the account key from the certificate key and state what each one signs
- Explain how the key authorization binds a challenge to a specific ACME account
- Recognise the encoding and header errors that make an otherwise correct client fail
Prerequisites
Verified against OpenSSL 3.5.x teaching target; 3.0+ minimum · OpenSSH 10.x teaching target; 8.2+ minimum for certificate workflows · OpenBao 2.6.x · Smallstep step-ca 0.30.x · Certbot / Pebble Certbot current release; Pebble 2.10.x ACME test server · Kubernetes (cross-course target) 1.36.x · PostgreSQL 17.x · 2026-08-26
ACME is a request-response protocol over HTTPS in which a client proves control of an identifier and receives a certificate for it, with no human in the path. Everything it does is expressed in four object types: an account, an order, an authorization per identifier, and a challenge per authorization. Knowing how those objects move between states is what lets you read a client log and say precisely which of them stopped.
The directory is the only URL you configure
An ACME client is configured with exactly one endpoint, the directory, and discovers everything else from it. Fetching the Let’s Encrypt production directory on 2026-08-26 returns the resource map the client will use for the rest of its life:
{
"newNonce": "https://acme-v02.api.letsencrypt.org/acme/new-nonce",
"newAccount": "https://acme-v02.api.letsencrypt.org/acme/new-acct",
"newOrder": "https://acme-v02.api.letsencrypt.org/acme/new-order",
"revokeCert": "https://acme-v02.api.letsencrypt.org/acme/revoke-cert",
"keyChange": "https://acme-v02.api.letsencrypt.org/acme/key-change",
"renewalInfo": "https://acme-v02.api.letsencrypt.org/acme/renewal-info",
"meta": {
"caaIdentities": ["letsencrypt.org"],
"profiles": { "classic": "...", "shortlived": "...", "tlsserver": "..." },
"website": "https://letsencrypt.org"
}
}
Two things in that response are worth noticing straight away.
The optional newAuthz resource is absent, because Let’s
Encrypt does not implement pre-authorization and has said it has
no plans to, so a client written against that resource simply
has nowhere to send the request. And the map is authoritative in
a way documentation is not: profile names, and whether renewal
information is offered at all, must be read from this object at
run time rather than hard-coded from a web page.
The account, and the key that signs everything
Registration is a POST to newAccount carrying a JSON Web
Signature. That first request is unusual because the client has
no account yet, so the protected header carries the public key
itself in a jwk field. The server creates the account and
returns its URL. From that point on every request the client
makes carries a kid field holding that account URL instead,
and the two fields are mutually exclusive: a server must reject
a request that contains both.
The account key is therefore the credential. It is not used for
TLS, it never appears in a certificate, and it does not expire
along with the certificates it obtained. RFC 8555 requires the
signature algorithm to be one of a small set, mandating support
for ES256, and forbids the none algorithm and MAC-based
signatures outright.
Order, authorization and challenge are three separate machines
sequenceDiagram
participant Client
participant Server
Client->>Server: GET the directory
Server-->>Client: resource URLs and meta
Client->>Server: POST newAccount, JWS with a jwk header
Server-->>Client: account URL, used as kid from now on
Client->>Server: POST newOrder with the identifier list
Server-->>Client: order pending, one authorization URL per identifier
Client->>Server: POST-as-GET the authorization
Server-->>Client: challenge list and token
Client->>Server: POST an empty JSON object to the challenge URL
Server-->>Client: challenge processing, then valid
Server-->>Client: authorization valid, order ready
Client->>Server: POST the CSR to the finalize URL
Server-->>Client: order processing, then valid with a certificate URL
Client->>Server: POST-as-GET the certificate URL
Server-->>Client: the PEM certificate chain
Read that exchange as three nested loops. The order is created once and holds one authorization per identifier requested. Each authorization offers a list of challenges, of which the client picks one and answers it. Signalling readiness is a POST of an empty JSON object to the challenge URL, and the specification is explicit that it goes to the challenge URL and not to the authorization URL, which is a common client bug.
stateDiagram-v2
[*] --> pending
pending --> ready: every authorization valid
pending --> invalid: an authorization failed
ready --> processing: finalize request accepted
processing --> valid: certificate issued
processing --> invalid: issuance error
An order is created pending, becomes ready only when every
one of its authorizations has reached valid, moves to
processing when the client posts the CSR to the finalize URL,
and ends valid with a certificate or invalid. Authorizations
run pending to valid or invalid, and a valid one can later
become expired, deactivated by the client or revoked by
the server. Challenges run pending to processing to valid
or invalid, and while a challenge sits in processing the
server may attempt validation several times without any state
change being visible to the client.
Finalize is where the second key appears
Up to the finalize call, exactly one key has been used and it is the account key. The finalize request carries a certificate signing request, and that CSR is self-signed by a completely different key: the key whose public half will end up inside the issued certificate and whose private half will sit on the server terminating TLS.
RFC 8555 makes the separation normative rather than advisory. The public key of an account key pair must not be included in a certificate, and a client must generate a fresh account key for every account creation or rollover. If you reuse one key for both roles you have merged the credential that can revoke your certificates with the credential that sits on a public-facing web server, which is exactly the concentration of risk the protocol is designed to avoid.
Two encoding rules on finalize catch people out. The csr field
is base64url-encoded DER with the PEM armour stripped and the
padding removed, which the specification points out is different
from PEM. And the CSR must request the same identifier set as
the original newOrder, so a client that adds a name at CSR time
gets a rejection rather than a bigger certificate.
Reading a successful run
When the whole exchange works, a client says very little. This is a complete issuance against an ACME test server:
$ certbot certonly --standalone -d web.lab.example
Account registered.
Requesting a certificate for web.lab.example
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/web.lab.example/fullchain.pem
Key is saved at: /etc/letsencrypt/live/web.lab.example/privkey.pem
This certificate expires on 2026-11-24.
“Account registered” is the newAccount call. “Requesting a
certificate” spans newOrder, the authorization, the challenge
and finalize. “Successfully received certificate” is the
POST-as-GET against the certificate URL. When something breaks,
the client message will name one of those phases, and the state
machines tell you which object to inspect: an order stuck in
pending means an authorization never reached valid, whereas
an order that reached ready and then failed points at the CSR
or at server policy rather than at your challenge plumbing.
Production discipline
- Back up the account key, and know where it lives. Losing it does not invalidate issued certificates, but it costs you the ability to revoke them and forces a fresh account with fresh authorizations at the worst possible moment.
- Never share one account key across environments. Staging and production accounts are separate objects on separate servers; sharing a key merges their blast radius for no operational gain.
- Read the directory at run time. Resource URLs, offered profiles and the presence of renewal information are server properties that change; a client that caches them in configuration breaks quietly when they move.
- Let the client tolerate unknown directory fields. Servers deliberately add fields clients have not seen, and a parser that rejects unknown keys will fail on a change that was meant to be backwards compatible.
- Instrument the phase, not just the exit code. Log which object was last seen and in which state, so a failed run tells you whether to investigate the challenge path, the CSR or the server policy.
Cross-course references
- Linux for Production Sysadmins - Part LXXII (Secrets) covers ownership, modes and forgotten copies of secrets at rest, which is exactly the treatment an ACME account key needs.
- Kubernetes for Production Sysadmins - Part CXIV (TLS) covers ACME issuers inside a cluster, where the account key becomes a Secret object and the order objects become cluster resources you can inspect.
- Observability for Production Sysadmins - Part XC (MetaMonitoring) covers avoiding circular assumptions, the trap you hit when the system that watches ACME issuance is itself reachable only over a certificate that ACME issued.
Quiz
Knowledge check · 4 questions
Q1. In a single ACME issuance, which key signs the certificate signing request sent to the finalize URL?
Q2. To tell the ACME server it is ready for validation, the client posts an empty JSON object to the authorization URL.
Q3. Describe how the key authorization value is constructed and explain what property it gives the challenge.
Q4. Work out which ACME object failed and what to change.
A newly written in-house ACME client runs against staging on 2026-08-26. It registers an account successfully, creates an order for api.example.com and internal.example.com, and the client log shows both authorizations reaching valid and the order reaching ready. The finalize call then returns an error and the order goes to invalid. The operator's first instinct is that the HTTP-01 challenge path is misconfigured.
Passing score: 75%. Answers are checked in this browser.