What version of the package are you using?
v0.25.3
What are you trying to do?
Run two *certmagic.Config instances sharing one *certmagic.Cache:
- Config A issues and serves wildcard certs (
*.example.com) via DNS-01.
- Config B issues and serves only exact-SAN certs via on-demand HTTP-01.
The TLS server picks A or B per handshake based on whether the SNI is currently administratively configured as a wildcard or not. The shared cache is required so a single CacheOptions.GetConfigForCert can route renewals to the right Config.
What steps did you take?
- Through Config A, obtain
*.example.com. It is cached and written to storage.
- Wildcard configuration is removed administratively. The cert remains in the cache (and in storage).
- A new specific hostname
test.example.com is added; it now routes to Config B.
- A TLS handshake arrives for
test.example.com. The server dispatches it to Config B's GetCertificate.
Time passes, the wildcard domain is removed, and the DNS challenge is removed. The wildcard cert expires.
What did you expect to happen, and what actually happened instead?
Expected: Config B refuses the cached wildcard cert (different issuance policy, expired) and falls through to obtainOnDemandCertificate to issue a fresh cert for test.example.com.
Actual: getCertificateFromCache's wildcard-name walk in handshake.go:142-152 finds *.example.com in the shared cacheIndex and returns it as matched = true to Config B. The follow-up renewal in optionalMaintenance runs against the SNI (test.example.com), which has no resource in storage; it errors. Because the cert is expired, handshake.go:463-465 still returns it, and it is served on every subsequent handshake. On-demand issuance for test.example.com is never attempted.
Logs repeatedly show:
on_demand renewing certificate on-demand failed
subjects=["*.example.com"] error="context canceled"
How do you think this should be fixed?
A combination of:
-
Per-Config opt-out of the wildcard cache walk. Add Config.DisableWildcardCacheFallback bool. When set, skip the label-replacement loop at handshake.go:142-152. Configs that should only serve exact-SAN matches enable it. Minimal change, fixes this report.
-
Optional ScopedCertificateSelector interface. A CertificateSelector that also implements ScopedToIndexedNames() bool (returning true) tells selectCert it only needs index hits for the looked-up name. Two effects:
selectCert skips the getAllCerts() fallback at handshake.go:195-197, so deployments with thousands of cached certs avoid a per-handshake []Certificate allocation proportional to cache size.
- Combined with (1) the selector is reached only for true exact-name hits, allowing it to reject wildcard SANs trivially.
It is a non-breaking addition, existing CertificateSelector implementations keep current behavior.
(1) alone resolves the user-visible bug; (2) additionally fixes the allocation hot path
Please link to any related issues, pull requests, and/or discussion
None found in a quick search
Bonus: What do you use CertMagic for, and do you find it useful?
For our SaaS redirect.pizza 🍕
Bug report written by Claude Opus 4.7.
What version of the package are you using?
v0.25.3What are you trying to do?
Run two
*certmagic.Configinstances sharing one*certmagic.Cache:*.example.com) via DNS-01.The TLS server picks A or B per handshake based on whether the SNI is currently administratively configured as a wildcard or not. The shared cache is required so a single
CacheOptions.GetConfigForCertcan route renewals to the rightConfig.What steps did you take?
*.example.com. It is cached and written to storage.test.example.comis added; it now routes to Config B.test.example.com. The server dispatches it to Config B'sGetCertificate.Time passes, the wildcard domain is removed, and the DNS challenge is removed. The wildcard cert expires.
What did you expect to happen, and what actually happened instead?
Expected: Config B refuses the cached wildcard cert (different issuance policy, expired) and falls through to
obtainOnDemandCertificateto issue a fresh cert fortest.example.com.Actual:
getCertificateFromCache's wildcard-name walk inhandshake.go:142-152finds*.example.comin the sharedcacheIndexand returns it asmatched = trueto Config B. The follow-up renewal inoptionalMaintenanceruns against the SNI (test.example.com), which has no resource in storage; it errors. Because the cert is expired,handshake.go:463-465still returns it, and it is served on every subsequent handshake. On-demand issuance fortest.example.comis never attempted.Logs repeatedly show:
How do you think this should be fixed?
A combination of:
Per-
Configopt-out of the wildcard cache walk. AddConfig.DisableWildcardCacheFallback bool. When set, skip the label-replacement loop athandshake.go:142-152. Configs that should only serve exact-SAN matches enable it. Minimal change, fixes this report.Optional
ScopedCertificateSelectorinterface. ACertificateSelectorthat also implementsScopedToIndexedNames() bool(returning true) tellsselectCertit only needs index hits for the looked-up name. Two effects:selectCertskips thegetAllCerts()fallback athandshake.go:195-197, so deployments with thousands of cached certs avoid a per-handshake[]Certificateallocation proportional to cache size.It is a non-breaking addition, existing
CertificateSelectorimplementations keep current behavior.(1) alone resolves the user-visible bug; (2) additionally fixes the allocation hot path
Please link to any related issues, pull requests, and/or discussion
None found in a quick search
Bonus: What do you use CertMagic for, and do you find it useful?
For our SaaS redirect.pizza 🍕
Bug report written by Claude Opus 4.7.