Reading ACME Renewal Information (ARI) with C#
Posted: (EET/GMT+2)
Certificate renewal automation is getting more important as public TLS certificate lifetimes become shorter. But how should an ACME client decide exactly when to renew?
The RFC 9773 from June 2025 defines what is called ACME Renewal Information, or ARI. Instead of every client using a fixed rule such as "renew 14 days before expiration", the certificate authority can provide a suggested renewal window.
This allows the CA to spread renewals over time and, if necessary, tell clients to renew earlier because of an exceptional event.
Today, many certificates are created using Let's Encrypt, and they now support ARI. ARI gives the certificate renewers an HTTPS endpoint using which the renewal schedule can be queried. Let's Encrypt's ACME directory currently contains a renewalInfo endpoint, which is currently set to:
https://acme-v02.api.letsencrypt.org/acme/renewal-info
It's worth remembering, that the suggested renewal window is not stored in the certificate or .pfx file. The certificate file contains the information needed to construct an ARI certificate identifier. The client then sends this identifier to the CA and receives the current renewal information.
The identifier consists of:
- the key identifier from the certificate's Authority Key Identifier (AKI) extension
- the certificate serial number in its DER representation.
Both values are Base64Url encoded and separated with a period ("."). As in the following:
base64url(authority-key-identifier).base64url(serial-number)
Here is a .NET 10 example that loads a certificate from a PFX file and queries Let's Encrypt for its ARI information:
using System.Net.Http.Json;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;
string pfxPath = args[0];
string? pfxPassword = Environment.GetEnvironmentVariable("PFX_PASSWORD");
using X509Certificate2 certificate = X509CertificateLoader.LoadPkcs12FromFile(
pfxPath, pfxPassword, X509KeyStorageFlags.EphemeralKeySet);
Console.WriteLine($"Subject: {certificate.Subject}");
Console.WriteLine($"Expires: {certificate.NotAfter:u}");
string certificateId = CreateAriCertificateId(certificate);
using HttpClient http = new HttpClient();
const string directoryUrl = "https://acme-v02.api.letsencrypt.org/directory";
using Stream directoryStream = await http.GetStreamAsync(directoryUrl);
using JsonDocument directory = await JsonDocument.ParseAsync(directoryStream);
string renewalInfoUrl = directory.RootElement.GetProperty("renewalInfo").GetString()
?? throw new InvalidOperationException("ACME server does not provide a renewalInfo endpoint.");
string requestUrl = $"{renewalInfoUrl.TrimEnd('/')}/{certificateId}";
AriResponse? ari = await http.GetFromJsonAsync<AriResponse>(requestUrl);
if (ari is null)
{
throw new InvalidOperationException("No ARI response received.");
}
Console.WriteLine($"Suggested renewal window: {ari.SuggestedWindow.Start:u} - " +
$"{ari.SuggestedWindow.End:u}");
if (ari.ExplanationURL is not null)
{
Console.WriteLine($"Explanation: {ari.ExplanationURL}");
}
static string CreateAriCertificateId(X509Certificate2 certificate)
{
X509Extension extension = certificate.Extensions["2.5.29.35"] ?? throw new InvalidOperationException(
"Certificate has no Authority Key Identifier.");
X509AuthorityKeyIdentifierExtension authorityKeyIdentifier = new(extension.RawData, extension.Critical);
ReadOnlyMemory<byte> keyIdentifier = authorityKeyIdentifier.KeyIdentifier
?? throw new InvalidOperationException("Authority Key Identifier has no keyIdentifier.");
byte[] serialNumber = certificate.SerialNumberBytes.ToArray();
// ASN.1 INTEGER values are signed. If the highest bit is set,
// DER uses a leading zero byte to keep the serial number positive.
if (serialNumber.Length > 0 && (serialNumber[0] & 0x80) != 0)
{
byte[] padded = new byte[serialNumber.Length + 1];
serialNumber.CopyTo(padded, 1);
serialNumber = padded;
}
return Base64UrlEncode(keyIdentifier.Span) + "." + Base64UrlEncode(serialNumber);
}
static string Base64UrlEncode(ReadOnlySpan<byte> data)
{
return Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
public sealed record AriResponse(AriWindow SuggestedWindow, string? ExplanationURL);
public sealed record AriWindow(DateTimeOffset Start, DateTimeOffset End);
For example, the response contains a window similar to:
{
"suggestedWindow": {
"start": "2026-10-20T02:00:00Z",
"end": "2026-10-21T02:00:00Z"
}
}
The exact dates depend on the certificate and can change over time.
A proper ARI client should not simply renew at the beginning of the window. Instead, RFC 9773 recommends selecting a random time inside the suggested window. The client should also periodically ask the CA for updated renewal information and respect the Retry-After response header. This is useful because the CA can change the suggested window after the certificate has already been issued.
For example, if a group of certificates needs to be replaced unexpectedly, the CA can use ARI to recommend earlier renewal instead of waiting for every client's normal expiration logic.
There is another useful ARI addition for a full ACME client: when creating the replacement certificate order, the client can send the old certificate identifier in the replaces field.
This tells the CA that the new order is replacing a specific existing certificate.
Tip: the private key in the PFX is not needed for the ARI lookup. ARI uses only public certificate information. If you already have the certificate as a .cer file, that is enough.
Also, do not upload a production PFX file to online certificate inspection tools just to check ARI. A PFX normally contains the private key and should be treated as sensitive.
For developers implementing certificate automation, ARI changes the question from "Is this certificate close enough to expiration to renew?" to "When does the certificate authority recommend that I renew this certificate?".
Hope this helps!