Asking Let's Encrypt when to renew: an ARI request built by hand in Python
Let's Encrypt's 7 October post moves the default certificate lifetime from 90 days to 64 days on 10 February 2027, and the post scheduled staging to switch on 14 October. The advice in it comes in two parts. If your clie
Let's Encrypt's 7 October post moves the default certificate lifetime from 90 days to 64 days on 10 February 2027, and the post scheduled staging to switch on 14 October. The advice in it comes in two parts. If your client supports ACME Renewal Information (ARI), "you should be all set". If it doesn't, renew "at approximately โ of the lifetime instead".
I wanted to see what "all set" means on the wire: what the client asks, what the CA answers, and how close the answer is to the two-thirds rule. ARI is a plain unauthenticated GET, so you can make the request yourself with the Python standard library and no ACME account. Everything below was run on 27 October 2026.
Step 1: the directory tells you where to ask
Every ACME server publishes a directory object. Let's Encrypt's production one, trimmed:
curl -s https://acme-v02.api.letsencrypt.org/directory
{
"meta": {
"caaIdentities": ["letsencrypt.org"],
"profiles": {
"classic": "https://letsencrypt.org/docs/profiles#classic",
"shortlived": "https://letsencrypt.org/docs/profiles#shortlived",
"tlsserver": "https://letsencrypt.org/docs/profiles#tlsserver"
},
"termsOfService": "https://letsencrypt.org/documents/LE-SA-v1.8-July-06-2026.pdf",
"website": "https://letsencrypt.org"
},
"newOrder": "https://acme-v02.api.letsencrypt.org/acme/new-order",
"renewalInfo": "https://acme-v02.api.letsencrypt.org/acme/renewal-info",
"revokeCert": "https://acme-v02.api.letsencrypt.org/acme/revoke-cert"
}
Two fields matter here. renewalInfo is the ARI endpoint, and RFC 9773 ยง3 says a server that offers ARI MUST put it there. profiles lists the certificate profiles you can request. The staging directory returned the same three names that day.
The directory gives names only. Lifetimes live on the profiles page: tlsserver is 45 days, shortlived is 160 hours. That page was last updated on 4 October and still lists classic at 90 days, three days before the 64-day announcement. So the directory won't tell your code how long its certificate lasts. The certificate will, and ARI doesn't need you to know.
Step 2: build the certificate ID
RFC 9773 ยง4.1 defines the path. Take the keyIdentifier from the certificate's Authority Key Identifier extension, take the DER bytes of the serial number without the tag and length, base64url both, strip the = padding, and join them with a dot. The RFC includes a worked example, which makes a free unit test.
The standard library's ssl module hands you the peer certificate as DER, and its parsed dictionary form has no AKI in it, so here is a small DER reader. It only handles what an X.509 certificate needs to get to the serial, the validity dates and the AKI.
import base64, json, socket, ssl, sys, urllib.request
from datetime import datetime, timezone
DIRECTORY = "https://acme-v02.api.letsencrypt.org/directory"
def read_tlv(buf, i):
tag = buf[i]
length = buf[i + 1]
i += 2
if length & 0x80:
n = length & 0x7F
length = int.from_bytes(buf[i:i + n], "big")
i += n
return tag, buf[i:i + length], i + length
def children(buf):
i, out = 0, []
while i < len(buf):
tag, val, i = read_tlv(buf, i)
out.append((tag, val))
return out
def parse_time(tag, val):
s = val.decode()
fmt = "%y%m%d%H%M%SZ" if tag == 0x17 else "%Y%m%d%H%M%SZ"
return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc)
AKI_OID = bytes.fromhex("551d23") # 2.5.29.35
def cert_fields(der):
_, cert, _ = read_tlv(der, 0)
tbs = children(children(cert)[0][1])
pos = 1 if tbs[0][0] == 0xA0 else 0 # skip [0] version
serial = tbs[pos][1] # DER INTEGER content bytes
validity = children(tbs[pos + 3][1])
not_before = parse_time(*validity[0])
not_after = parse_time(*validity[1])
aki = None
for tag, val in tbs:
if tag != 0xA3: # [3] extensions
continue
for _, ext in children(children(val)[0][1]):
parts = children(ext)
if parts[0][1] == AKI_OID:
aki_seq = children(parts[-1][1])[0][1]
for t, v in children(aki_seq):
if t == 0x80: # [0] keyIdentifier
aki = v
return serial, not_before, not_after, aki
The serial is the one place it's easy to get wrong. DER integers are signed, so a serial whose first byte has the top bit set carries a leading 0x00. The RFC wants those bytes as encoded, leading zero included. Converting the serial to an int and back with the minimum byte length drops the zero and gives you an ID the server has never heard of. Taking the INTEGER's content bytes straight from the DER avoids the problem.
def b64url(b):
return base64.urlsafe_b64encode(b).decode().rstrip("=")
def ari_id(aki, serial):
return b64url(aki) + "." + b64url(serial)
Step 3: ask, and compare with two thirds
The rest connects to a host, pulls its certificate, builds the ID and asks Let's Encrypt. The first line checks the RFC's worked example, serial 00:87:65:43:21 with its leading zero.
def fetch_cert(host):
ctx = ssl.create_default_context()
with socket.create_connection((host, 443), timeout=10) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as tls:
return tls.getpeercert(binary_form=True)
def get_json(url):
req = urllib.request.Request(url, headers={"User-Agent": "ari-check/0.1"})
with urllib.request.urlopen(req, timeout=10) as r:
return json.load(r), r.headers.get("Retry-After")
# Self-test against the worked example in RFC 9773 section 4.1
assert ari_id(bytes.fromhex("69885B6B87464041E1B37B847BA0AE2CDE01C8D4"),
bytes.fromhex("0087654321")) == "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE"
directory, _ = get_json(DIRECTORY)
print("profiles:", ", ".join(directory["meta"]["profiles"]))
base = directory["renewalInfo"]
for host in sys.argv[1:]:
serial, nb, na, aki = cert_fields(fetch_cert(host))
life = na - nb
two_thirds = nb + life * 2 / 3
cid = ari_id(aki, serial)
print(f"\n{host}")
print(f" notBefore {nb:%Y-%m-%d %H:%M}Z notAfter {na:%Y-%m-%d %H:%M}Z")
print(f" lifetime {life.total_seconds() / 86400:.2f} days")
print(f" 2/3 point {two_thirds:%Y-%m-%d %H:%M}Z")
print(f" ARI id {cid}")
try:
info, retry = get_json(f"{base}/{cid}")
except Exception as e:
print(f" ARI no answer ({e})")
continue
w = info["suggestedWindow"]
start = datetime.fromisoformat(w["start"].replace("Z", "+00:00"))
end = datetime.fromisoformat(w["end"].replace("Z", "+00:00"))
print(f" ARI window {start:%Y-%m-%d %H:%M}Z -> {end:%Y-%m-%d %H:%M}Z")
print(f" as share of lifetime {(start - nb) / life:.3f} -> {(end - nb) / life:.3f}")
print(f" Retry-After {retry}s")
if "explanationURL" in info:
print(f" explanation {info['explanationURL']}")
I pointed it at two hosts that use Let's Encrypt and one that doesn't:
$ python3 ari_check.py letsencrypt.org community.letsencrypt.org google.com
profiles: classic, shortlived, tlsserver
letsencrypt.org
notBefore 2026-09-04 14:34Z notAfter 2026-12-03 14:34Z
lifetime 90.00 days
2/3 point 2026-11-03 14:34Z
ARI id uVnyjs8i8IbTN0j_dhQYuoLYVYc.BUOTO-OGs6KzrdU08hA7yLZf
ARI window 2026-11-02 17:18Z -> 2026-11-04 12:29Z
as share of lifetime 0.657 -> 0.677
Retry-After 22509s
community.letsencrypt.org
notBefore 2026-10-17 00:01Z notAfter 2027-01-15 00:01Z
lifetime 90.00 days
2/3 point 2026-12-16 00:01Z
ARI id QBUtJnntMiCe35pyHdYyH4EMgQw.BaDPfcSmSl1rE-x3CK9ycwDS
ARI window 2026-12-15 02:45Z -> 2026-12-16 21:56Z
as share of lifetime 0.657 -> 0.677
Retry-After 18328s
google.com
notBefore 2026-09-28 18:38Z notAfter 2026-12-21 18:38Z
lifetime 84.00 days
2/3 point 2026-11-23 18:38Z
ARI id db7Ed66J9kQ3fc-xaB8dGuvcNFk.AKNba7BW76hrEPyYtBP27-k
ARI no answer (HTTP Error 404: Not Found)
Three things in that output.
The window is close to two thirds but it isn't a point. For both certificates it ran from 65.7% to 67.7% of the lifetime, about 43 hours wide, with the two-thirds mark inside it. Two certificates issued six weeks apart got the same shape, which suggests a rule applied to the lifetime rather than a fixed day count. Two certificates aren't enough to say what the rule is, and nothing stops Let's Encrypt changing it. That's the point of asking.
Retry-After came back at 22,509 and 18,328 seconds, about 6.3 and 5.1 hours. Under ยง4.3 that's not a minimum. It "indicates the desired (i.e., both requested minimum and maximum) amount of time to wait", so a client should check again after roughly that long, not whenever it likes.
The ID only means something to the CA that issued the certificate. google.com's certificate isn't from Let's Encrypt, so its ID got a 404. A tool that manages certificates from more than one CA has to read each CA's own directory.
What a client should do with the window
RFC 9773 ยง4.2 recommends picking a uniformly random time inside the window and renewing right away if that time has passed. Otherwise the client schedules itself for that time if it can, renews now if the pick falls before its next normal wake-up, and failing both, sleeps until Retry-After and asks again. The random pick spreads load across the window. A window whose end is not after its start is invalid, and the client has to treat it as no answer at all. Here's that as a function with a two-thirds fallback for when ARI fails:
import random
def next_renewal(nb, na, window=None, now=None):
now = now or datetime.now(timezone.utc)
if window:
start, end = window
if end > start:
pick = start + (end - start) * random.random()
return max(pick, now)
# No usable ARI answer: fall back to two thirds of the lifetime
return max(nb + (na - nb) * 2 / 3, now)
Two more rules from the RFC catch out home-grown scripts. A client MUST NOT check a certificate's renewal info after it has expired, or after the certificate has been replaced. And a long-term error, such as a missing Retry-After or any non-5xx HTTP error, means making the next request once six hours have passed (or a locally configured default) rather than hammering the endpoint. When the client does renew, ยง5 lets it put the old certificate's ID in a replaces field on the new order, which tells the CA which certificate the new one supersedes.
Why the fallback should be a fraction, not a day count
The two-thirds fallback stays sensible when the lifetime changes. A rule written as a fixed number of days doesn't. On a 90-day certificate, two thirds is day 60. On a 64-day one it's day 42.7, and on a 45-day tlsserver certificate it's day 30. A cron job that renews every 60 days was fine at 90 and lets a 45-day certificate expire. Computing from notBefore and notAfter, as the script does, means the next lifetime change needs no code change.
The staging change in the 7 October post is how to find out before February. A second staging change is scheduled for today. From 27 October, Let's Encrypt staging is due to validate domains from seven places, one primary and six remote perspectives instead of four, with production planned for 10 November if staging goes well. A staging run this week tests both the new lifetime and the new validation path in one go. I didn't issue a staging certificate for this post, so I can't show a 64-day one here. The ARI request above works against the staging renewalInfo URL the same way.
Written with AI assistance from my own notes and test results. I checked every fact and command before publishing.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.