ACME Client Guide¶
This guide covers everything you need to issue, renew, and manage TLS certificates
using the lacme ACME client library. Both the async (Client) and synchronous
(SyncClient) APIs are shown side by side.
Async vs Sync API¶
lacme provides two client classes with identical capabilities:
Client-- async-native, usesasync withandawaitSyncClient-- blocking wrapper, useswithand direct calls
import asyncio
from lacme import Client, FileStore
from lacme.challenges.http01 import HTTP01Handler
async def main():
store = FileStore("~/.lacme")
handler = HTTP01Handler()
async with Client(
store=store,
challenge_handler=handler,
contact="mailto:admin@example.com",
) as client:
bundle = await client.issue("example.com")
print(f"Certificate expires: {bundle.expires_at}")
asyncio.run(main())
from lacme import SyncClient, FileStore
from lacme.challenges.http01 import HTTP01Handler
store = FileStore("~/.lacme")
handler = HTTP01Handler()
with SyncClient(
store=store,
challenge_handler=handler,
contact="mailto:admin@example.com",
) as client:
bundle = client.issue("example.com")
print(f"Certificate expires: {bundle.expires_at}")
Tip
The SyncClient accepts both sync and async challenge handlers. If you pass
a SyncChallengeHandler (with plain def provision/def deprovision), it is
automatically wrapped to run in a thread executor. If you pass an async handler,
it is used directly.
HTTPX2 Client Boundary¶
lacme 1.1 uses HTTPX2 exclusively. Client(http_client=...) and
SyncClient(http_client=...) accept an httpx2.AsyncClient; HTTPX clients are
not compatible.
An async Client leaves an injected HTTP client caller-owned:
import httpx2
from lacme import Client
async with httpx2.AsyncClient() as http:
async with Client(http_client=http) as client:
directory = await client.directory()
SyncClient has different lifecycle requirements because it owns a private
event loop. Pass it a fresh, unused HTTPX2 client and do not share that client:
ownership transfers to SyncClient, which closes the HTTP client before
shutting down its event loop.
MockACMEServer.as_transport() likewise returns an httpx2.MockTransport for
use with an HTTPX2 client. Requests, responses, transports, and exceptions from
HTTPX and HTTPX2 must not be mixed.
HTTPX2 transport and status exceptions propagate from normal HTTP failures, so
catch them from httpx2:
try:
await client.directory()
except httpx2.TransportError as exc:
print(f"ACME connection failed: {exc}")
except httpx2.HTTPStatusError as exc:
print(f"ACME server returned {exc.response.status_code}")
When lacme creates the client, HTTPX2 verifies HTTPS with the operating
system's trust store. ca_bundle selects an explicit CA bundle, while
client_cert and client_key configure a client identity for mTLS. When a
client is injected, configure its TLS policy directly on that client.
Account Management¶
Creating an Account¶
Accounts are created automatically during issue(), but you can create one
explicitly for more control:
Finding an Existing Account¶
Use only_return_existing=True to look up an account without creating a new one.
This requires the account key already stored (or passed explicitly):
Note
If no matching account exists, the ACME server returns an
accountDoesNotExist error, which lacme raises as
AccountDoesNotExistError.
Deactivating an Account¶
Warning
Account deactivation is permanent. The ACME server will reject all future requests signed with this account key.
Key Rollover¶
Replace the account key on the ACME server. The new key is saved to the store on success:
Warning
If the key rollover succeeds on the server but fails to save locally, lacme
logs a CRITICAL message. The new key exists only in memory at that point.
Back it up immediately.
Certificate Issuance¶
Single Identifier¶
The issue() method orchestrates the full ACME flow:
- Ensure an account exists (create if needed)
- Create an order
- Solve challenges for each identifier
- Finalize with a CSR
- Download the certificate chain
The returned CertBundle contains:
| Field | Type | Description |
|---|---|---|
domain |
str |
Primary identifier string |
domains |
tuple[str, ...] |
All identifier strings |
cert_pem |
bytes |
Leaf certificate (PEM) |
fullchain_pem |
bytes |
Leaf followed by the CA-provided chain |
key_pem |
bytes |
Private key (PEM) |
issued_at |
datetime |
Issuance timestamp |
expires_at |
datetime |
Expiry timestamp |
cert_path |
Path | None |
File path (if using FileStore) |
fullchain_path |
Path | None |
File path (if using FileStore) |
key_path |
Path | None |
File path (if using FileStore) |
Multiple-Identifier (SAN) Certificates¶
Pass a list of identifiers. The first identifier's string value becomes the Common Name (CN):
IP Address Certificates¶
Pass typed values from Python's ipaddress module to request IPv4 or IPv6
identifiers. lacme serializes them as RFC 8738 ip identifiers and emits
IPAddress entries in the certificate SAN extension:
from ipaddress import ip_address
bundle = await client.issue(ip_address("192.0.2.10"))
mixed_bundle = await client.issue([
"node.internal",
ip_address("192.0.2.10"),
ip_address("2001:db8::1"),
])
This contract is the same for Client.create_order(), Client.issue(), and
their SyncClient counterparts. Plain strings are always DNS identifiers, so
"192.0.2.10" deliberately requests a DNS SAN while
ip_address("192.0.2.10") requests an IP SAN.
DNS identifiers must use certificate-form ASCII. Supply internationalized
names as valid IDNA A-labels (for example, xn--...), not Unicode U-labels.
Letter case is preserved in callbacks and result metadata but ignored when
matching authorizations, CSRs, and certificates.
IPv6 scope identifiers such as fe80::1%eth0 are interface-local notation,
not certificate identities, and are rejected. When using challenge_map, use
the same typed IP object as the map key; this also lets a numeric DNS name and
an IP address with identical text select different handlers.
Challenge-handler callbacks, CertBundle.domain, CertBundle.domains, stored
certificate keys, and lifecycle events use unchanged DNS strings and canonical
IP strings. RenewalManager recovers the identifier types from the stored
certificate's SAN extension when it reissues the certificate.
IP identifiers can use http-01, but RFC 8738 forbids dns-01 validation for
them; lacme rejects that combination before creating an order.
Wildcard Certificates¶
Wildcard certificates require DNS-01 validation:
from lacme import Client, FileStore, DNS01Handler
from lacme.challenges.providers.cloudflare import CloudflareDNSProvider
provider = CloudflareDNSProvider(
api_token="your-cloudflare-token",
zone_id="your-zone-id",
)
handler = DNS01Handler(provider=provider)
async with Client(
store=FileStore("~/.lacme"),
challenge_handler=handler,
contact="mailto:admin@example.com",
) as client:
bundle = await client.issue(
["example.com", "*.example.com"],
challenge_type="dns-01",
)
Note
lacme raises a ValueError if you attempt to use http-01 with a
wildcard domain. Wildcard domains always require dns-01.
Challenge Types¶
HTTP-01 with Standalone Server¶
The HTTP01Handler can run a minimal HTTP server on port 80 to respond to
challenges:
from lacme.challenges.http01 import HTTP01Handler
handler = HTTP01Handler()
server = await handler.start_server(host="0.0.0.0", port=80)
async with Client(
store=store,
challenge_handler=handler,
) as client:
bundle = await client.issue("example.com")
server.close()
await server.wait_closed()
HTTP-01 with ASGI Middleware¶
Serve challenge responses from your existing web application:
from lacme.asgi import ACMEChallengeMiddleware
from lacme.challenges.http01 import HTTP01Handler
handler = HTTP01Handler()
# Wrap any ASGI app
app = ACMEChallengeMiddleware(your_app, handler)
Requests to /.well-known/acme-challenge/{token} are intercepted; everything
else passes through to the inner app.
DNS-01 with Providers¶
DNS-01 challenges work by creating TXT records. See the DNS Providers guide for full setup instructions.
from lacme import DNS01Handler
from lacme.challenges.providers.cloudflare import CloudflareDNSProvider
provider = CloudflareDNSProvider(
api_token="your-token",
zone_id="your-zone-id",
)
handler = DNS01Handler(
provider=provider,
propagation_delay=10.0,
propagation_timeout=120.0,
)
Mixed Challenge Types¶
Use the challenge_map parameter to assign different challenge types and
handlers per identifier. This is useful when some DNS identifiers need DNS-01
(e.g., wildcards) while others can use HTTP-01:
from lacme.challenges.http01 import HTTP01Handler
from lacme import DNS01Handler
from lacme.challenges.providers.cloudflare import CloudflareDNSProvider
http_handler = HTTP01Handler()
dns_provider = CloudflareDNSProvider(
api_token="your-token",
zone_id="your-zone-id",
)
dns_handler = DNS01Handler(provider=dns_provider)
async with Client(
store=store,
challenge_handler=http_handler, # default for identifiers not in map
contact="mailto:admin@example.com",
) as client:
bundle = await client.issue(
["example.com", "*.example.com", "api.example.com"],
challenge_type="http-01", # default challenge type
challenge_map={
# Wildcard must use DNS-01
"*.example.com": ("dns-01", dns_handler),
},
)
Identifiers not present in challenge_map use the default challenge_type and
the client's challenge_handler.
Certificate Revocation¶
Revoke with Account Key¶
Revoke a certificate using the ACME account that issued it:
Revoke with Certificate Key¶
Revoke using the certificate's own private key. This does not require an ACME account:
from lacme import private_key_from_pem
cert_key = private_key_from_pem(bundle.key_pem)
async with Client(
directory_url="https://acme-v02.api.letsencrypt.org/directory",
) as client:
await client.revoke_with_cert_key(
bundle.cert_pem,
cert_key,
reason=RevocationReason.SUPERSEDED,
)
Revocation Reason Codes¶
| Code | Name | Value |
|---|---|---|
| 0 | UNSPECIFIED |
0 |
| 1 | KEY_COMPROMISE |
1 |
| 2 | CA_COMPROMISE |
2 |
| 3 | AFFILIATION_CHANGED |
3 |
| 4 | SUPERSEDED |
4 |
| 5 | CESSATION_OF_OPERATION |
5 |
Auto-Renewal¶
Using Client.auto_renew()¶
Start a background task that checks stored certificates and renews them when they approach expiry:
async with Client(
store=store,
challenge_handler=handler,
contact="mailto:admin@example.com",
) as client:
# Issue the initial certificate
await client.issue("example.com")
# Start auto-renewal (checks every 12 hours, renews 30 days before expiry)
task = await client.auto_renew(
interval_hours=12.0,
days_before_expiry=30,
on_renewed=lambda bundle: print(f"Renewed: {bundle.domain}"),
)
# Your application runs here...
await asyncio.sleep(3600)
# Cancel when shutting down
task.cancel()
Using RenewalManager Directly¶
For more control, use RenewalManager directly:
from lacme import RenewalManager
manager = RenewalManager(
client=client,
store=store,
interval_hours=12.0,
days_before_expiry=30,
challenge_type="http-01",
on_renewed=lambda bundle: print(f"Renewed: {bundle.domain}"),
max_jitter_seconds=600.0, # random delay to avoid thundering herd
)
# Run a single check-and-renew pass
renewed = await manager.check_and_renew()
# Or start the continuous background loop
task = manager.start()
Tip
The on_renewed callback accepts both sync and async functions. If it
returns an awaitable, lacme will await it automatically.
External Account Binding¶
Some CAs (ZeroSSL, enterprise CAs) require External Account Binding (EAB).
Pass the eab_kid and eab_hmac_key to the client:
You can also pass EAB credentials per-call to create_account():
Note
Both eab_kid and eab_hmac_key must be provided together. The HMAC key
must be base64url-encoded.
Custom ACME Servers¶
Using step-ca or Other Private CAs¶
Point directory_url at your CA's ACME directory. For CAs using a private
root certificate, pass ca_bundle:
async with Client(
directory_url="https://ca.internal:8443/acme/acme/directory",
ca_bundle="/path/to/ca-root.pem",
store=store,
challenge_handler=handler,
) as client:
bundle = await client.issue("service.internal")
HTTPS Enforcement¶
By default, lacme requires every ACME request URL to use HTTPS, including URLs discovered in the directory and later account, order, authorization, challenge, and certificate responses:
Lacme does not follow redirects for ACME requests, even when an injected HTTP client enables them. Configure the directory and advertised resources with their final request URLs.
For local development or trusted networks, set allow_insecure=True:
async with Client(
directory_url="http://localhost:8080/directory",
allow_insecure=True,
store=store,
challenge_handler=handler,
) as client:
bundle = await client.issue("dev.local")
Warning
allow_insecure=True permits HTTP for the entire ACME flow, including
server-discovered resource URLs. Never use it on an untrusted network.
ACME requests contain cryptographic signatures, but an HTTP transport
allows interception and replay attacks.
Client Certificate Authentication¶
Some CAs require mTLS for API access. Pass client_cert and client_key:
async with Client(
directory_url="https://ca.corp.example.com/acme/directory",
ca_bundle="/etc/pki/corp-ca.pem",
client_cert="/etc/pki/my-client.pem",
client_key="/etc/pki/my-client-key.pem",
store=store,
challenge_handler=handler,
) as client:
bundle = await client.issue("app.corp.example.com")
Rate Limit Awareness¶
Let's Encrypt enforces rate limits (50 certificates per registered domain per week). lacme can track issuance locally and prevent requests that would exceed the limit.
Setup¶
from lacme import Client, FileStore, RateLimitTracker
from lacme.ratelimit import FileRateLimitStore
store = FileStore("~/.lacme")
rate_store = FileRateLimitStore(base=store.base)
tracker = RateLimitTracker(
store=rate_store,
limit=50, # Let's Encrypt default
warn_threshold=0.9, # Warn at 90% (45 certs)
block=True, # Block issuance at limit
)
# Or create from FileStore directly:
tracker = RateLimitTracker.from_file_store(store)
async with Client(
store=store,
challenge_handler=handler,
rate_limit_tracker=tracker,
) as client:
bundle = await client.issue("example.com")
Checking Limits Before Issuance¶
status = client.check_rate_limits(["example.com", "www.example.com"])
print(f"Allowed: {status.allowed}")
print(f"Counts: {status.counts}") # {"example.com": 3}
print(f"Warnings: {status.warnings}") # ["example.com: 45/50 ... (warning)"]
When block=True (the default), issue() raises RateLimitPreventedError
if the limit would be exceeded. The check happens before any network requests
are made.
Typed IP identifiers are excluded from this registered-domain accounting. In a mixed DNS/IP order, only the DNS identifiers are checked and recorded.
Tip
For accurate registered domain extraction with complex TLDs (e.g.,
foo.co.uk), pass a custom registered_domain_func using a library
like tldextract:
Storage¶
FileStore¶
Persists account keys and certificates to disk with safe permissions:
Directory layout:
~/.lacme/
account.key (PEM, 0o600)
certs/
example.com/
cert.pem (leaf, 0o644)
fullchain.pem (0o644)
key.pem (private key, 0o600)
meta.json (0o644)
Most DNS names retain the readable directory layout above. Values that are not
valid directory components on every supported platform, exceed 200 UTF-8
bytes, or begin with the reserved lacme-v2- prefix use a deterministic
lacme-v2-<sha256> directory instead. This includes IPv6 addresses and
wildcard names. meta.json, load_cert(), list_certs(), and every public
bundle field continue to expose the original string. Earlier raw directories
whose names now require encoding are not discovered automatically. Move them
to the encoded path, or remove the raw directory before reissuing.
list_certs() rejects unexpected metadata-bearing directories rather than
silently accepting an ambiguous layout.
The store key remains the primary identifier's string value. Consequently, a numeric DNS identifier and a typed IP identifier with the same text select the same stored-certificate slot; the most recent save wins.
MemoryStore¶
In-memory store for testing -- no filesystem access:
Loading Stored Certificates¶
store = FileStore("~/.lacme")
# Load a single certificate
bundle = store.load_cert("example.com")
if bundle is not None:
print(f"Expires: {bundle.expires_at}")
# List all stored certificates
for bundle in store.list_certs():
print(f"{bundle.domain}: expires {bundle.expires_at}")
Deleting Certificates¶
Remove a certificate from the store: