curl (60) Unable to Get Local Issuer: Debian 13

10 min read

A script that worked yesterday now dies with curl error 60. Or a monitoring check against your mail server starts failing, while browsers load the same site without complaint. curl (60) unable to get local issuer certificate means the client could not build a chain from the server's certificate to a CA it trusts. That can be the server's fault, the client's fault or the clock's fault. Most guides stop at "use fullchain.pem". That fixes one of those causes and does nothing for the rest.

This post gives you a triage flow for Debian 13 (trixie, curl 8.14.1, ca-certificates 20250419). You will read the chain the server actually sends, decide which side is broken, and fix that side. The flow covers web servers, Postfix and Dovecot, private CAs, and runtimes that ship their own CA bundle. It does not use curl -k. That flag switches off the check rather than fixing anything.

The error message

From curl:

curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html

The same failure from openssl s_client shows up as one or both of these lines:

verify error:num=20:unable to get local issuer certificate
verify error:num=21:unable to verify the first certificate

Exit code 60 is CURLE_PEER_FAILED_VERIFICATION: the server's certificate did not pass validation. The OpenSSL numbers are more specific. Code 20 (X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY) means OpenSSL could not find the issuer of a certificate. Code 21 (X509_V_ERR_UNABLE_TO_VERIFY_LEAF_SIGNATURE) means the chain contains a single certificate that is not self-signed, so no signature could be checked.

Quick fix for the most common cause: the server sends only the leaf

If you run the server and use Certbot, check that the TLS config points at fullchain.pem and not cert.pem. According to the Certbot docs, cert.pem contains only the server certificate. fullchain.pem contains the server certificate followed by the intermediates. The nginx docs say ssl_certificate must contain the primary certificate first, then the intermediates.

ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

Restarting a service drops its connections, but a reload doesn't. Test the config first, then reload:

sudo nginx -t
sudo systemctl reload nginx

Browsers often hide this mistake because they cache intermediates or fetch missing ones. curl, OpenSSL, mail clients and most libraries do neither. That's why "works in Firefox" proves nothing here.

Diagnose: read the chain the server sends

Run this from the failing client, with the real hostname so SNI picks the right virtual host:

openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null

-showcerts prints the certificates exactly as the server sent them. This is not a validated chain. Look at two places in the output: the Certificate chain block and the final Verify return code. Here is a server that sends only the leaf (output shortened):

depth=0 CN = example.com
verify error:num=20:unable to get local issuer certificate
verify return:1
depth=0 CN = example.com
verify error:num=21:unable to verify the first certificate
verify return:1
---
Certificate chain
 0 s:CN = example.com
   i:CN = Example Intermediate CA
---
    Verify return code: 21 (unable to verify the first certificate)

Only entry 0 appears, and its issuer (i:) is an intermediate the server never sent. Use this table to read the result:

What you seeMost likely cause
Only 0 s: in the chain, code 21 (often after 20 at depth 0)Server sends the leaf only (cause 1)
Chain has 0 and 1, code 20 at depth 1Client does not trust the root: stale or missing store, or a private CA that isn't installed (cause 2)
Code 9 (not yet valid) or 10 (expired) while the dates look fineClient clock is wrong (cause 3)
Issuer is your company, a firewall vendor or an antivirus productTLS-intercepting proxy (cause 4)
curl and openssl succeed, but Python or Java failsRuntime with its own CA bundle (cause 5)
Code 19 (self-signed certificate in chain)The chain ends in a root that is not in your store, which usually means a private CA

Run the same command from a second, known-good machine. If it returns Verify return code: 0 (ok) there, the server is fine and the problem is on your client.

Mail and IMAP daemons

Postfix and Dovecot get this wrong as often as web servers do, and no browser covers it up. s_client can run STARTTLS itself:

openssl s_client -connect mail.example.com:25 -starttls smtp -servername mail.example.com -showcerts </dev/null
openssl s_client -connect mail.example.com:587 -starttls smtp -servername mail.example.com -showcerts </dev/null
openssl s_client -connect mail.example.com:143 -starttls imap -servername mail.example.com -showcerts </dev/null
openssl s_client -connect mail.example.com:993 -servername mail.example.com -showcerts </dev/null

Then check which files the daemons load:

postconf -n | grep -E 'smtpd_tls_(cert_file|chain_files)'
doveconf -n | grep ssl_server_cert_file
grep -c 'BEGIN CERTIFICATE' /etc/letsencrypt/live/mail.example.com/cert.pem

If a configured file contains only one BEGIN CERTIFICATE, the daemon sends only the leaf.

Prove it with openssl verify -untrusted

Save the certificates the server sent into separate files, then check whether adding the intermediates is enough to verify the leaf:

openssl s_client -connect example.com:443 -servername example.com -showcerts </dev/null 2>/dev/null | awk '/-----BEGIN CERTIFICATE-----/{n++; f="srv-" n ".pem"} f{print > f} /-----END CERTIFICATE-----/{f=""}'
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt srv-1.pem
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt -untrusted chain.pem -show_chain srv-1.pem

In this example, chain.pem is the intermediate file from the CA, or Certbot's chain.pem. -untrusted supplies certificates that can be used to build the chain but are not trusted themselves. Suppose the second verify succeeds and the first fails with error 20. Then the client trust store is fine and the server just needs to send that intermediate. If both fail, the root is missing on the client.

What curl -v says

curl -sv -o /dev/null https://example.com/ 2>&1 | grep -iE 'cafile|capath|proxy|issuer|certificate problem'

The output shows which CA file and directory curl loaded, whether a proxy environment variable was used, and the issuer of the certificate it received. To find out whether one extra trust anchor would fix the problem, pass it for a single request:

curl --cacert /root/corp-root.crt -sv -o /dev/null https://intranet.example.com/

Fixes, cause by cause

1. Server sends the leaf only

Point every TLS service at a file that contains the full chain:

  • nginx: ssl_certificate to fullchain.pem, as shown above.
  • Apache 2.4.8+: SSLCertificateFile to fullchain.pem.
  • Postfix: smtpd_tls_cert_file must contain the server certificate followed by the issuing CA certificates. On Postfix 3.4 and later, the preferred option is smtpd_tls_chain_files. It takes the key first, then the certificate, then the chain.
  • Dovecot 2.4 (trixie ships 2.4.1): ssl_server_cert_file must contain the server certificate followed by the intermediates.
smtpd_tls_chain_files =
    /etc/letsencrypt/live/mail.example.com/privkey.pem,
    /etc/letsencrypt/live/mail.example.com/fullchain.pem
ssl_server_cert_file = /etc/letsencrypt/live/mail.example.com/fullchain.pem
ssl_server_key_file = /etc/letsencrypt/live/mail.example.com/privkey.pem
sudo postfix check
sudo systemctl reload postfix
sudo doveconf -n >/dev/null
sudo systemctl reload dovecot

2. Client trust store is missing, stale or lacks your private CA

First check the package and its config:

dpkg -s ca-certificates | grep '^Version'
grep '^!' /etc/ca-certificates.conf
ls -l /etc/ssl/certs/ca-certificates.crt

Minimal container images may ship without the package. Lines in /etc/ca-certificates.conf that start with ! are CAs someone deliberately disabled. To install or update the package:

sudo apt update
sudo apt install ca-certificates

To add a private CA, copy the root certificate into /usr/local/share/ca-certificates. update-ca-certificates only picks up PEM files with a .crt extension there. It then rebuilds /etc/ssl/certs and the single-file bundle /etc/ssl/certs/ca-certificates.crt. Convert a DER file first if you need to:

openssl x509 -inform DER -in corp-root.cer -out corp-root.crt
openssl x509 -in corp-root.crt -noout -subject -fingerprint -sha256
sudo install -m 0644 corp-root.crt /usr/local/share/ca-certificates/corp-root.crt
sudo update-ca-certificates

Before you trust the fingerprint, compare it with one you got through another channel. Install the root, not the intermediate. Keep OpenSSL patched along with ca-certificates, and restart long-running clients after library updates.

3. Clock is wrong

timedatectl status
openssl x509 -in srv-1.pem -noout -dates

If System clock synchronized is no, or the local time falls outside the certificate's notBefore and notAfter dates, fix the time rather than the certificate. With systemd-timesyncd or another NTP client installed, timedatectl set-ntp true turns synchronization on. Fresh VMs restored from old snapshots and boards without an RTC battery often have this problem.

4. TLS-intercepting proxy

Check env | grep -i _proxy and the issuer at depth 0 or 1. If a corporate appliance re-signs traffic, the clean fix is to install its CA the same way as a private CA above, after confirming with whoever runs the proxy. If you do not recognise the issuer and nobody admits to running a proxy, treat it as a possible man-in-the-middle and investigate before trusting anything.

5. Runtimes with their own trust store

curl and the openssl CLI use the system bundle. Other tools might not:

  • Python: Requests uses the certifi bundle. Debian's python3-certifi 2025.1.31+ds-1 is patched to return the Debian CA store. A certifi installed by pip in a virtualenv ships its own bundle, so your private CA is not in it. Find out which bundle is active, then point Requests at the system bundle with REQUESTS_CA_BUNDLE:
python3 -c 'import ssl; print(ssl.get_default_verify_paths())'
python3 -c 'import certifi; print(certifi.where())'
export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
  • Java: Java uses the JKS keystore /etc/ssl/certs/java/cacerts, which the ca-certificates-java package maintains through a ca-certificates hook. If you installed Java before that package, or the keystore looks incomplete, install the package and run sudo update-ca-certificates -f to re-import. -f removes the symlinks in /etc/ssl/certs and rebuilds them.

SSL_CERT_FILE and SSL_CERT_DIR are useful for testing, because OpenSSL-based tools (and curl) read them. Keep in mind that they replace the default location. Setting SSL_CERT_FILE globally to a file with only your private CA breaks every public site. If a systemd service needs it, set it in that unit only, and point it at the full system bundle.

How to prevent it

  • After every certificate deployment, check from a machine that is not the server, with -verify_return_error so a broken chain fails hard. Put it in the Certbot deploy hook or your monitoring. The Certbot renewal post covers deploy hooks, including for Let's Encrypt's opt-in 45-day certificates (the default lifetime drops to 45 days in February 2028).
  • Check mail ports as well as 443. Postfix and Dovecot often load copies of old files.
  • Distribute private CAs through config management into /usr/local/share/ca-certificates, and include Java and venv bundles in the rollout.
  • Run NTP everywhere, including in VM templates.
  • Make fullchain.pem part of your web server security baseline.
openssl s_client -connect example.com:443 -servername example.com -verify_return_error -brief </dev/null

Checklist

  1. Run openssl s_client -showcerts -servername from the failing client and read the chain depth and Verify return code.
  2. Only depth 0 with code 21: fix the server config to use the full chain (nginx, Apache, Postfix, Dovecot).
  3. Full chain with code 20 at depth 1: update ca-certificates or install the private root and run update-ca-certificates.
  4. Code 9 or 10: check timedatectl status.
  5. Unexpected issuer: look for a proxy, and confirm it before trusting it.
  6. Only Python or Java fails: check certifi.where(), REQUESTS_CA_BUNDLE and /etc/ssl/certs/java/cacerts.
  7. Confirm with openssl verify -untrusted and a clean curl -v. Never ship -k.

Sources

Comments

Latest alerts