Let's Encrypt: "unable to get issuer certificate" (error 2), forced reissuance and rate limits

AI-generated summary

jmx reported a bug where Discourse installations fail to verify valid Let’s Encrypt certificates (specifically with the YR2 chain), resulting in error 2: unable to get issuer certificate. This verification failure triggers unnecessary forced reissuance requests, which can quickly exhaust Let’s Encrypt rate limits and block HTTPS from starting, even if existing certificates are still valid. The root cause is that the original verification command (openssl verify -CAfile <(openssl x509 -in ca.cer)) only reads the first certificate from the bundle, missing the cross-signed root required for validation.

jmx provided certificate files and demonstrated that using the -untrusted flag resolves the issue. supermathie reviewed the attachments and noted that the existing verification logic was convoluted and incorrect. He proposed a more robust fix in PR #1137 that verifies the leaf certificate directly using the full chain, rather than relying on side effects of reading fullchain.cer. jmx agreed that this approach was cleaner and closed the original PR in favor of the new one. supermathie subsequently merged the fix, thanking jmx for clearly identifying the problem and providing the initial solution.

A Discourse install or rebuild can reject a valid Let’s Encrypt certificate with:

C=US, O=Let's Encrypt, CN=YR2
error 2 at 1 depth lookup: unable to get issuer certificate
error fullchain.cer: verification failed

I reproduced this immediately after successful RSA issuance. The failed check triggers an unnecessary --force request, which can lead to HTTP 429 / rateLimited / “too many certificates”. In my installation, ECDSA issuance was then blocked and HTTPS did not start.

With the affected chains, each rebuild or container restart can trigger unnecessary forced reissuance of RSA and ECDSA certificates, even when they are still valid. Successful reissuances consume the Let’s Encrypt quota, so repeated rebuilds or restarts can quickly reach the rate limit.

This can go unnoticed while HTTPS continues working with certificates already installed. Deleting and reinstalling an instance with the same hostname does not reset that quota. If required certificates are missing on the new installation, rate-limited issuance can prevent HTTPS from starting.

Fix

In templates/web.letsencrypt.ssl.template.yml, inside cert_exists(), replace:

- openssl verify -CAfile <(openssl x509 -in ca.cer) fullchain.cer
+ openssl verify -untrusted ca.cer fullchain.cer

Keep the rest of the function unchanged. The corrected template must be included in the next container rebuild to update /usr/local/bin/letsencrypt.

If you are already rate-limited, the patch does not reset the quota. Apply the fix and respect the retry after time before attempting further issuance.

PR: FIX: preserve the Let's Encrypt issuer chain in cert_exists - Pull Request #1136 - discourse/discourse_docker - GitHub

Confirm the chain-verification problem

From the affected certificate directory inside the container (/shared/letsencrypt/<hostname>, or <hostname>_ecc), compare:

openssl verify -CAfile <(openssl x509 -in ca.cer) fullchain.cer
openssl verify -untrusted ca.cer fullchain.cer

In the reproduced case, the first command fails with error 2; the second returns:

fullchain.cer: OK

Cause and validation

openssl x509 -in ca.cer reads only the first certificate from the issuer bundle. On the observed YR2 chain, it drops the cross-signed Root YR needed to reach ISRG Root X1.

-untrusted ca.cer supplies the complete issuer bundle while keeping trust anchors in the system store, preserving the intent of the 2021 fix (#576).

I verified actual RSA and ECDSA chains inside the container. A full rebuild with the patch reused both existing certificates: two Skip messages, both chains installed, and nginx -t passed.

Related reports

411097
413078

PR: FIX: preserve the Let's Encrypt issuer chain in cert_exists - Pull Request #1136 - discourse/discourse_docker - GitHub

6 Likes

Please post your good certificate and chain here so we can evaluate.

3 Likes

Thanks @supermathie for looking into this. I’ve attached fullchain.cer and ca.cer from the affected instance.
fullchain.cer.txt (5.9 KB)
ca.cer.txt (3.8 KB)

The OpenSSL output below shows the original verification command failing with error 2, and -untrusted succeeding with the same certificate and chain.

openssl version
openssl verify -CAfile <(openssl x509 -in ca.cer) fullchain.cer
openssl verify -show_chain -untrusted ca.cer fullchain.cer

OpenSSL 3.5.7 9 Jun 2026 (Library: OpenSSL 3.5.7 9 Jun 2026)
C=US, O=Let's Encrypt, CN=YR2
error 2 at 1 depth lookup: unable to get issuer certificate
error fullchain.cer: verification failed
fullchain.cer: OK
Chain:
depth=0: CN=hubprod.surikai.fr (untrusted)
depth=1: C=US, O=Let's Encrypt, CN=YR2 (untrusted)
depth=2: C=US, O=ISRG, CN=Root YR (untrusted)
depth=3: C=US, O=Internet Security Research Group, CN=ISRG Root X1

I encountered this issue in two separate environments. I’ve been running the fix on my development instance since I opened the PR, and it has been working as expected.

2 Likes

For reference, the ACME client creates the following relevant files in ${LETSENCRYPT_DIR}:

  • ${DISCOURSE_HOSTNAME}.cer: contains the issued certificate
  • ca.cer: contains the certificate chain and is somewhat confusingly named
  • fullchain.cer: combines the above two files

The most correct command would be:

openssl verify -untrusted ca.cer ${DISCOURSE_HOSTNAME}.cer

The existing command is a little convoluted (and is incorrect as it doesn’t actually test the chain is valid to a system root), and anything else relies on side effects or assumptions.

I’ve extended the suggested change here:

https://github.com/discourse/discourse_docker/pull/1137

2 Likes

Makes sense. Verifying the leaf certificate directly is cleaner than relying on OpenSSL reading only the first certificate in fullchain.cer.

I’ll close PR #1136 in favor of yours.

Happy to test the new PR if needed.

1 Like

Merged :partying_face:

Thanks @jmx for clearly identifying the problem and proposing the fix - it was good; I just wanted to do a bit more.

2 Likes

This topic was automatically closed after 14 hours. New replies are no longer allowed.