Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Challenge Validation

A challenge is how a client proves to acme-proxy that it controls the identifiers it is asking for. Every authorization created by newOrder carries one challenge per enabled type; satisfying any one of them makes the authorization valid, and an order becomes ready once every authorization is.

acme-proxy implements all three challenge types RFC 8555 and RFC 8737 define:

  • http-01 — serve a token over HTTP.
  • dns-01 — publish a TXT record.
  • tls-alpn-01 — present a special certificate in a TLS handshake.

This is validation acme-proxy performs against its own clients. It is separate from how the relay signer backend satisfies an upstream CA’s challenges on your behalf; the two are configured independently and need not match.

Configuration

[challenge]
# Types offered, in this order. Empty or unknown = startup error.
enabled = ["http-01", "dns-01"]

# Skip validation entirely. Testing only.
bypass = false

# Budget for one validation attempt.
timeout_ms = 5000

[challenge] is a per-profile section: each endpoint can offer a different set of types. See Profiles & Routing.

Reference

enabled (Array) — Default: ["http-01"] | Env: ACME_PROXY_CHALLENGE__ENABLED

Which types each new authorization offers, and in what order. Clients pick one. Valid values are http-01, dns-01 and tls-alpn-01. An empty list, or an unrecognised name, is a startup error — even when bypass is on, because a bypassing server still has to advertise a challenge for the client to trigger.

bypass (Boolean) — Default: false | Env: ACME_PROXY_CHALLENGE__BYPASS

Mark a triggered challenge valid immediately, with no network check. With this on, [filter] is the only access control there is — which is why it is not the default.

timeout_ms (Integer) — Default: 5000 | Env: ACME_PROXY_CHALLENGE__TIMEOUT_MS

Budget for one validation attempt, applied at the registry level whatever the type. It bounds a job attempt in the runner, not a request — see Validation runs in the job queue — and it must stay below server.request_timeout_ms, which server::profile::build_all refuses to start otherwise.

max_in_flight_per_account (Integer) — Default: 32 | Env: ACME_PROXY_CHALLENGE__MAX_IN_FLIGHT_PER_ACCOUNT

How many of one account’s challenges may be validating at once. 0 is no limit.

A validation is queued work that reaches out to an address the client named, and an account can create as many orders as it likes. Without a cap, one busy — or hostile — account fills the runner with outbound probes while signings, revocations and CRL regenerations wait behind them. A trigger over the cap is answered 429 rateLimited with a Retry-After, and the challenge is left pending: the client re-triggers it once one of its own validations has settled, and loses no order.

Raise it for a deployment that renews many certificates at once from one account; the ceiling that matters is jobs.max_concurrent, which is how many validations actually run in parallel.

Per-type keys live under [challenge.http_01] and [challenge.tls_alpn_01]; see those pages. dns-01 has no table of its own — it is governed by dns.resolver.

Bypass is not a shortcut

With challenge.bypass = true, [filter] is the only access control the server has. Anyone who can reach the endpoint can obtain a certificate for any name it will accept, without proving anything.

Bypass exists for two legitimate cases: local testing (as in the Quick Start), and a deployment where an IPAM-backed filter such as ipam is genuinely the authority on which host may hold which name, making a network round-trip redundant.

It defaulted to true early in this project’s life. That was reconsidered: an empty filter.rules plus the default bind on every interface made the combination an open CA, so the default is now false.

The two state machines

An authorization and its challenges are separate objects with separate statuses, and the edge between them is the one worth internalising: an authorization becomes valid as soon as any one of its challenges does. The siblings stay pending for ever and that is correct.

stateDiagram-v2
    direction LR
    state "authorization" as A {
        [*] --> a_pending: created with the order
        a_pending --> a_valid: any one challenge valid
        a_pending --> a_invalid: a challenge failed
        a_pending --> a_deactivated: §7.5.2
        a_valid --> a_deactivated: §7.5.2
        a_pending --> a_expired: expires passed
    }
    state "challenge" as C {
        [*] --> c_pending: created with the authorization
        c_pending --> c_valid: proof accepted
        c_pending --> c_invalid: proof refused — terminal
    }
    c_valid --> a_valid: promotes its parent

a_invalid and c_invalid are terminal: the client must create a new order, and re-triggering the same challenge will not retry it. Deactivation is the operator- or client-initiated exit — see Deactivation below.

Validation runs in the job queue

Triggering a challenge with POST /chall/{id} does not perform the check. It claims the challenge, writes a challenge_validate job and answers straight away with the challenge in the processing state, plus a Retry-After. The job runner performs the outbound check and records the verdict.

RFC 8555 has the states for exactly this. §7.1.6: challenges “transition to the processing state when the client responds to the challenge”. §8.2 pairs that with a Retry-After on the challenge resource, which is what the client polls against. certbot, acme.sh and lego all poll.

sequenceDiagram
    participant C as ACME client
    participant P as acme-proxy
    participant W as job runner
    participant T as The name being proven<br/>(port 80 / 443 / DNS)
    participant D as SQLite

    C->>P: POST /chall/{id}
    P->>D: claim: pending → processing
    P->>D: enqueue challenge_validate
    P-->>C: 200 + challenge object (processing)<br/>+ Retry-After + Link: rel="up"
    W->>D: claim the job
    rect rgb(240, 240, 240)
        Note over W,T: in the runner, under challenge.timeout_ms
        W->>T: fetch token / query TXT / TLS handshake
        T-->>W: answer, or timeout
    end
    W->>D: one transaction:<br/>challenge + authorization + order
    Note over D: "is every authorization valid?"<br/>is read INSIDE this transaction
    C->>P: POST /chall/{id} (retry — not a state change)
    P-->>C: 200 + challenge object — valid or invalid

Three consequences:

  • challenge.timeout_ms bounds a job attempt, not an HTTP request. It is therefore independent of server.request_timeout_ms, and the server no longer refuses to start when it exceeds it.
  • A client that points a name at an unreachable host no longer occupies one of server.max_concurrent_requests while the server waits for it.
  • The server still needs egress to the client. For http-01 and tls-alpn-01 it must be able to open a connection back to the machine requesting the certificate — a common source of “the order just sits at pending” in firewalled networks.

A validation is attempted once: a check that ran records its verdict, pass or fail. The job is retried only when the attempt could not happen at all — the database was unreachable, or the endpoint’s profile is not mounted by the process that picked the row up.

Both outcomes are 200

A validation failure returns 200 OK with the challenge object, its status set to invalid and an error member describing what went wrong. It is not a 4xx.

This follows RFC 8555 §7.5.1, and it is load-bearing: certbot’s acme library surfaces an HTTP error status as a transport failure, which would obscure the actual reason the challenge failed. Read the challenge object’s status, not the HTTP status.

Responses also carry a Link: rel="up" header pointing at the authorization, which that same library requires.

A challenge that reaches invalid is terminal. The client must create a new order; re-triggering the same challenge will not retry it.

Wildcards

A wildcard identifier such as *.example.com is accepted only when dns-01 is among enabled — it is the only challenge type that can prove control of a whole subtree. Otherwise newOrder refuses with rejectedIdentifier, naming dns-01.

For a wildcard identifier:

  • The authorization is created on the base name (example.com), with "wildcard": true in the authorization object.
  • It offers dns-01 alone, even if other types are enabled.

Ordering example.com and *.example.com together therefore produces two authorizations on the same base name, and the TXT record for each goes to the same _acme-challenge.example.com. acme-proxy matches any TXT record at that name, so publishing both values side by side works.

Only a single leading *. is legal. *.*.example.com and foo.*.example.com are rejected as malformed.

What happens on success

Success is committed as one transaction covering the challenge, its authorization, and the order — including the “is every authorization now valid?” read that promotes the order to ready. Doing that read inside the transaction is deliberate: two concurrent validations of one order could otherwise each read before the other’s write landed, and neither would promote the order.

Note the promotion depends on every authorization being valid, not every challenge. An authorization with three challenges needs only one of them.

Deactivation

A client can deactivate an authorization it no longer wants by POSTing {"status": "deactivated"} to the authorization URL (§7.5.2). If the order had already reached ready, it is demoted back to pending.

Deactivation is refused once the order is valid — at that point the certificate exists, and revocation, not deactivation, is what undoes it.