Skip to main content
RunBook Academy

Secrets, PKI & CertificatesX · ACME and Certificate AutomationAutomation

The ACME protocol — accounts, orders, authorizations and the two keys

Intermediate⏱ ~24 mincertbotcurl

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

Not yet marked complete on this device.

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Q1. In a single ACME issuance, which key signs the certificate signing request sent to the finalize URL?

  2. Q2. To tell the ACME server it is ready for validation, the client posts an empty JSON object to the authorization URL.

  3. Q3. Describe how the key authorization value is constructed and explain what property it gives the challenge.

  4. 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.