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.
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 Domain¶
The issue() method orchestrates the full ACME flow:
- Ensure an account exists (create if needed)
- Create an order
- Solve challenges for each domain
- Finalize with a CSR
- Download the certificate chain
The returned CertBundle contains:
| Field | Type | Description |
|---|---|---|
domain |
str |
Primary domain |
domains |
tuple[str, ...] |
All domains in the certificate |
cert_pem |
bytes |
Leaf certificate (PEM) |
fullchain_pem |
bytes |
Full chain (leaf + intermediates) |
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) |
Multi-Domain (SAN) Certificates¶
Pass a list of domains. The first domain becomes the Common Name (CN):
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 domain. This is useful when some domains 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 domains 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),
},
)
Domains 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 the directory URL to use HTTPS:
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
Never use allow_insecure=True in production. 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.
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)
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: