Automatic HTTPS (ACME)
Certificates come from Let’s Encrypt, ZeroSSL or any other RFC 8555 CA with nothing else running alongside the app: no certbot, no cron job, no reverse proxy. The package orders the certificate, answers the CA’s challenge from this same app, stores the result, renews it before it expires, and swaps each new certificate into Kestrel without a restart. It is the maintained replacement for the archived LettuceEncrypt.
It uses only the cryptography built into .NET (ECDsa, CertificateRequest, SslStreamCertificateContext).
There is no BouncyCastle or Certes dependency, and it is trim and AOT clean.
| GitHub | |
| Downloads |
dotnet add package Shiny.AspNet.AcmeQuick start
Section titled “Quick start”using Shiny.AspNet.Acme;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://*:80", "https://*:443"); // HTTP-01 is always port 80
builder.Services.AddAcme(o =>{ o.Domains = ["example.com", "www.example.com"]; o.Email = "ops@example.com"; o.AcceptTermsOfService = true; o.DirectoryUrl = AcmeDirectories.LetsEncryptStaging; // until it works, then remove this line});
var app = builder.Build();app.UseHttpsRedirection();app.MapGet("/", () => "hello over real TLS");app.Run();AddAcme wires everything; there is no app.UseAcme() to remember and no Kestrel configuration to touch:
- Kestrel — through
ConfigureHttpsDefaults, every HTTPS endpoint that has no certificate of its own serves the ACME certificate: anhttps://URL,ListenAnyIP(443, l => l.UseHttps()), or anappsettings.jsonendpoint without aCertificatesection. An endpoint that brings its own certificate keeps it. - HTTP-01 — a startup filter puts the challenge responder in front of the app’s whole pipeline.
- Renewal — a hosted service starts the loop once the app has started (Kestrel has to be listening before the CA can validate anything) and stops it with the host.
Once the app is running, the loop takes over:
- It loads the stored certificate, if there is one that covers every configured name and has not expired. A restart does not order a new certificate.
- If there is none, it registers or finds the ACME account and orders one. For each name it serves the challenge, asks the CA to validate it, and waits for the result. Then it finalizes the order with a CSR and downloads the certificate chain.
- It stores the certificate and installs it. The next TLS handshake gets it.
- It sleeps until renewal time, checking every 12 hours.
Until the first certificate arrives, TLS handshakes on the ACME endpoints are refused (Kestrel logs them at debug level). The cleartext endpoint serves normally throughout. At start, the app logs a warning when no HTTPS endpoint ended up serving the ACME certificate, and a note when HTTP-01 is enabled and nothing is bound to port 80.
Challenges
Section titled “Challenges”| HTTP-01 (default) | TLS-ALPN-01 | |
|---|---|---|
| The CA connects to | http://{name}:80/.well-known/acme-challenge/{token} |
{name}:443, offering only ALPN acme-tls/1 |
| Needs | a cleartext endpoint reachable on port 80 | the ACME HTTPS endpoint reachable on port 443 |
| IP address names | yes | no (SNI cannot carry an address) |
| Enable with | o.Challenges = AcmeChallengeTypes.Http01 |
o.Challenges = AcmeChallengeTypes.TlsAlpn01 |
The ports are fixed by the protocol. The CA always connects to 80 or 443, whatever port you would rather use. Binding somewhere else is fine as long as a router or proxy forwards 80 or 443 to it.
HTTP-01 is answered by middleware that a startup filter places before everything in Program.cs —
UseHttpsRedirection, HSTS, authentication, host filtering, fallback routes — any of which would
otherwise redirect, challenge or swallow the CA’s anonymous GET. It only answers tokens it is currently
validating; anything else under /.well-known/acme-challenge/ goes on down the pipeline as usual.
TLS-ALPN-01 (RFC 8737) needs the server to answer a TLS handshake with a special certificate when a
client offers acme-tls/1. .NET never tells a server which ALPN protocols the client offered, so the
package uses Kestrel’s TlsClientHelloBytesCallback to read the ClientHello (SNI and ALPN) before the
handshake, and OnAuthenticate to answer a pending challenge with the challenge certificate and
acme-tls/1 as the only protocol. Everything else — ordinary browsers, and a stray acme-tls/1 probe for
a name that is not being validated — gets the normal handshake.
A CA gives one attempt per authorization, so the client cannot fall back from one challenge to the other
in the middle of an order. If both are enabled, HTTP-01 is preferred when the CA offers it. Wildcards
(*.example.com) need DNS-01, which needs write access to your DNS, so they are refused up front with a
message saying so.
Options
Section titled “Options”AcmeOptions |
Default | Notes |
|---|---|---|
Domains |
— | One certificate covers them all; the first is the subject. IDNs are converted; IP addresses are accepted (RFC 8738) |
Email |
null |
Account contact. Expiry and incident notices go here |
AcceptTermsOfService |
false |
Must be true. The error names the terms URL from the CA’s directory |
DirectoryUrl |
AcmeDirectories.LetsEncrypt |
Also LetsEncryptStaging, ZeroSsl, or any CA’s directory URL |
ExternalAccountBinding |
null |
new AcmeExternalAccountBinding(keyId, hmacKey), required by ZeroSSL |
Challenges |
Http01 |
Flags: Http01, TlsAlpn01 |
KeyAlgorithm |
EcdsaP256 |
EcdsaP384, or Rsa with RsaKeySize (2048) |
Profile |
null |
Certificate profile for CAs that offer them, such as Let’s Encrypt’s shortlived |
StorePath |
{LocalApplicationData}/Shiny.AspNet/acme |
Used by the default store |
Store |
null |
A custom IAcmeCertificateStore |
RenewAfterLifetimeFraction |
2/3 |
60 days into a 90-day certificate |
UseRenewalInfo |
true |
ACME Renewal Information (RFC 9773) when the CA offers it |
RenewalCheckInterval |
12 hours | Jittered ±10% |
RetryDelay / MaxRetryDelay |
10 min / 12 h | Exponential backoff with jitter; a CA’s Retry-After is never undercut |
PollInterval |
2 s | Used when the CA sends no Retry-After |
OrderTimeout |
5 min | One issuance, end to end |
BackchannelHttpHandler |
null |
For a proxy, or a CA that is not on the internet |
AddAcme uses the options pattern, so a second call adds to the same options, and
builder.Services.Configure<AcmeOptions>(builder.Configuration.GetSection("Acme")) works too.
ZeroSSL and other EAB CAs
Section titled “ZeroSSL and other EAB CAs”builder.Services.AddAcme(o =>{ o.Domains = ["example.com"]; o.AcceptTermsOfService = true; o.DirectoryUrl = AcmeDirectories.ZeroSsl; o.ExternalAccountBinding = new AcmeExternalAccountBinding("EAB KID from the dashboard", "EAB HMAC key");});External Account Binding (RFC 8555 §7.3.4) ties the ACME account to your account at the CA. The HMAC key is the base64url string the dashboard shows. It is used once, to sign the account registration. If the CA’s directory says EAB is required and none is configured, the error says so before anything is sent.
Renewal
Section titled “Renewal”A certificate is renewed at two thirds of its lifetime, minus up to 2% jitter that is stable for each certificate, so that servers started together do not all renew in the same minute. When the CA’s directory offers ACME Renewal Information (RFC 9773, which Let’s Encrypt offers), the CA’s suggested window takes precedence. The loop picks a random moment inside that window once, and names the certificate being replaced in the new order. That is how a CA gets a certificate it is about to revoke replaced before it is revoked. If the CA publishes an explanation URL, it is logged as a warning.
A failed issuance is retried with exponential backoff and jitter, from RetryDelay up to
MaxRetryDelay. The old certificate stays in service the whole time, and the log says when it
expires. Each failure is logged at error level with the CA’s own problem detail, which for a failed
validation usually names the exact URL or address it could not reach.
Hot swap
Section titled “Hot swap”Every Kestrel endpoint wired to ACME asks for its certificate on every handshake, so installing a new
one is a single reference assignment. New connections get the new certificate immediately. Connections
already open keep the one they negotiated, and nothing is dropped or restarted. The intermediate goes out
with it: the certificate is handed to SslStream as an SslStreamCertificateContext built with the chain
the CA returned, rather than as a bare certificate whose intermediates SslStream would have to find in a
machine store (on Linux and in containers, usually none).
var acme = app.Services.GetRequiredService<AcmeCertificateManager>();
acme.CertificateChanged += (_, certificate) => logger.LogInformation("Now serving {Thumbprint}, expires {NotAfter}", certificate.Thumbprint, certificate.NotAfter);
await acme.RenewAsync(); // force a new certificate now: key compromise, new namesawait acme.EnsureCertificateAsync(); // load, or order if missing or due; await it at startupacme.Certificate, acme.CertificateContext and acme.NextRenewal report the current state.
Kestrel wiring in detail
Section titled “Kestrel wiring in detail”AddAcme sets three hooks on every endpoint’s HttpsConnectionAdapterOptions through
ConfigureHttpsDefaults:
| Hook | What it does |
|---|---|
ServerCertificateSelector |
What Kestrel requires to accept an HTTPS endpoint with no certificate; also the fallback when a ClientHello could not be parsed. |
TlsClientHelloBytesCallback |
Parses the SNI name and ALPN list out of the ClientHello Kestrel sniffs before the handshake. |
OnAuthenticate |
Sets the handshake’s certificate context (with its chain) for that SNI name, or the TLS-ALPN-01 challenge certificate and acme-tls/1. |
An endpoint configured with its own certificate after the defaults (l.UseHttps(myCertificate), or a
Certificate section in configuration) keeps serving it; one given its own ServerCertificateSelector
is left entirely alone.
To wire an endpoint explicitly — inside your own ConfigureHttpsDefaults, or on an endpoint that has a
certificate of its own — call UseAcme on it. ACME’s certificate then comes first, and the endpoint’s own
certificate becomes the fallback served until ACME has one:
builder.WebHost.ConfigureKestrel(kestrel =>{ var acme = kestrel.ApplicationServices.GetRequiredService<AcmeCertificateManager>(); kestrel.ConfigureHttpsDefaults(https => { https.SslProtocols = SslProtocols.Tls13; https.UseAcme(acme); });
// or one endpoint, keeping a certificate to serve before the first issuance kestrel.ListenAnyIP(8443, l => l.UseHttps(bootstrapCertificate, https => https.UseAcme(acme)));});HTTP/3
Section titled “HTTP/3”Kestrel refuses OnAuthenticate on an endpoint that also speaks HTTP/3 (NotSupportedException at
bind), so an endpoint with Protocols = HttpProtocols.Http1AndHttp2AndHttp3 cannot use the full wiring.
For such an endpoint, clear OnAuthenticate in its own configuration:
kestrel.ListenAnyIP(443, l =>{ l.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; l.UseHttps(https => https.OnAuthenticate = null); // the ACME selector stays, and follows renewals});The ACME certificate is then served through the selector on both TCP and QUIC, and still follows renewals,
but TLS-ALPN-01 is not answered on that endpoint and the intermediate is only sent if SslStream can find
it in a machine store. Use HTTP-01, or keep a separate HTTP/1.1+HTTP/2 endpoint on 443 for TLS-ALPN-01.
Storage
Section titled “Storage”The default DirectoryAcmeCertificateStore keeps:
{StorePath}/{ca-host}-{hash}/account.json account key (PKCS#8) and account URL{StorePath}/{ca-host}-{hash}/{name}.pfx certificate, private key and chain (PKCS#12, no password)Each CA directory URL gets its own folder, so staging and production never mix. The certificate file
name includes a hash of the configured names, so changing Domains orders a new certificate instead of
loading one that no longer covers them.
On Linux and macOS, files are created with mode 0600 and directories with 0700. The mode is set at
creation, not changed afterwards, so the key is never readable by anyone else. Writes go to a temporary
file and are renamed into place, so a crash never leaves a partly written key. On Windows the files
inherit the folder’s ACL. The default location under the service account’s profile is private to that
account, but choose StorePath carefully.
For a database, a secret manager or storage shared between instances, implement IAcmeCertificateStore.
It has four methods: load and save the account, and load and save a PKCS#12 blob. Set it as o.Store.
Everything it holds contains private keys.
Many certificates: the registry
Section titled “Many certificates: the registry”AcmeCertificateManager holds one certificate for a fixed list of names. An app that fronts many hosts,
added and removed while it runs, each with its own certificate (a YARP edge replacing Nginx Proxy Manager,
say), uses AcmeCertificateRegistry instead:
builder.Services.AddAcmeRegistry(o =>{ o.Email = "ops@example.com"; o.AcceptTermsOfService = true; o.DirectoryUrl = AcmeDirectories.LetsEncryptStaging; o.Store = myStore; // optional; a directory by default o.FallbackCertificate = fallback; // optional; for SNI names no entry covers});
var app = builder.Build();var acme = app.Services.GetRequiredService<AcmeCertificateRegistry>();
acme.Set("grafana", ["grafana.example.com"]); // add, or change the namesawait acme.IssueAsync("grafana", ct); // explicit; throws AcmeException on failureawait acme.RemoveAsync("grafana");
acme.CertificateChanged += (_, e) => Log(e.Name, e.Certificate);acme.IssuanceFailed += (_, e) => Alert(e.Name, e.Exception, e.IsRenewal);It is wired exactly like AddAcme: one HTTP-01 responder for every entry (including entries added after
the app started), every Kestrel HTTPS endpoint without a certificate of its own selecting by SNI, and
renewal while the app runs. Use AddAcme or AddAcmeRegistry, not both.
| Member | What it does |
|---|---|
Set(name, domains) |
Adds an entry or replaces its names. Changing the names keeps the current certificate serving; the next IssueAsync or scheduled renewal orders one for the new list. |
IssueAsync(name, ct) |
Orders now, serves the result, starts renewing it. Throws AcmeException. |
RenewAsync(name, ct) |
Forces a renewal of an entry that has a certificate. |
ImportAsync(name, cert, chain?, ct) |
Adopts a certificate from elsewhere. There are also overloads for PEM (fullchain.pem + privkey.pem) and PKCS#12 bytes with a password. It is saved under the entry’s storage name, so renewal and restarts treat it like one the registry issued, and nothing is ordered. |
RemoveAsync(name) |
Stops serving and renewing. The stored certificate is kept, so setting the entry again reuses it. |
LoadAsync(name, ct) |
Awaits the entry’s initial load from the store. |
Entries / GetEntry(name) |
Name, domains, State (None, Issuing, Issued, Failed), certificate, NotAfter, NextRenewal, LastError. |
SelectCertificate(sni) |
Exact name (case-insensitive), then a *. wildcard on a served certificate, then FallbackCertificate, otherwise null, which refuses the handshake. |
Every entry shares one ACME account, one store, one HttpClient to the CA and one challenge responder,
so TLS-ALPN-01 (Challenges = AcmeChallengeTypes.TlsAlpn01) is also answered for every entry through the
same ClientHello hook. When a renewal replaces an imported certificate that the CA did not issue to
this account, the order is retried without naming the old certificate (replaces), since some CAs
refuse that.
Metrics
Section titled “Metrics”Both the manager and the registry report on the Shiny.AspNet.Acme meter (AcmeTelemetry.MeterName):
builder.Services.AddOpenTelemetry().WithMetrics(m => m.AddMeter(AcmeTelemetry.MeterName));| Instrument | Kind | Attributes |
|---|---|---|
shiny.acme.certificate.issuances |
counter | acme.certificate.name, acme.issuance.result (success / failure), error.type on failure (the CA’s problem type) |
shiny.acme.certificate.expiry |
observable gauge (days) | acme.certificate.name: the registry entry, or the manager’s first domain |
Alert on shiny.acme.certificate.expiry < 14. A renewal that keeps failing still leaves a few weeks
before that fires.
AcmeCertificateManager also raises IssuanceFailed (with the exception) whenever an order fails,
whether from the background loop or an explicit call.
- Kestrel TCP HTTPS endpoints (HTTP/1.1 and HTTP/2 over ALPN) serve the ACME certificate and follow renewals; HTTP/3 endpoints do with the caveat above.
- IIS, HTTP.sys and reverse proxies terminate TLS themselves and never see this certificate.
- No DNS-01 challenge, so no wildcards are ordered (an imported wildcard certificate is served and matched by SNI), and no revocation or account key rollover API.
AI Coding Assistant
Section titled “AI Coding Assistant”Step 1 — Add the marketplace:
claude plugin marketplace add shinyorg/skillsStep 2 — Install the plugin:
claude plugin install shiny@shinyOne plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.
Step 1 — Add the marketplace:
copilot plugin marketplace add https://github.com/shinyorg/skillsStep 2 — Install the plugin:
copilot plugin install shiny@shinyOne plugin installs all 48 Shiny skills. Your agent loads only the skill relevant to what you're building, so there's no cost to having them all available.


