curl (60) SSL Certificate Problem: Certificate Has Expired

Fix 'curl: (60) SSL certificate problem: certificate has expired' — old CA bundles, the 2021 Let's Encrypt root, wrong clocks, or a real expiry.

curl: (60) SSL certificate problem: certificate has expired
More details here: https://curl.se/docs/sslcerts.html

This error means curl built the certificate chain for the server, and one link in it is outside its validity dates according to your machine. It is the same condition browsers report as NET::ERR_CERT_DATE_INVALID, but when it shows up in a script, a cron job, a CI pipeline, or a Docker container, the cause is on the client at least as often as on the server. Do not reach for -k yet. Work through these in order.

1. Check whether the server certificate really expired

echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -dates -issuer

If notAfter is in the past, the server’s certificate is expired and the fix belongs to whoever runs it. The renewal steps for every common platform are in Certificate has expired: how to fix it on any platform. If you cannot run openssl where you are, the SSL certificate checker reports the served certificate and chain from an independent server.

If notAfter is in the future, the server is probably fine and the problem is one of the following.

2. Check the local clock

date -u

Compare it with a real clock. Containers inherit the host’s clock, but VMs restored from snapshots, Raspberry Pis without a battery-backed clock, and machines that have been powered off for months can be years out. A clock in the past makes current certificates “not yet valid”; curl reports both conditions with the same message on some versions.

Fix: sudo timedatectl set-ntp true on systemd systems, or sudo ntpdate pool.ntp.org for a one-off correction. On Windows: w32tm /resync.

3. Update the CA bundle (the Let’s Encrypt root problem)

This is the single most common cause on older servers, and it can appear on a machine that worked yesterday.

On 30 September 2021, the DST Root CA X3 certificate expired. Let’s Encrypt had cross-signed its own root, ISRG Root X1, with DST Root CA X3 so that very old devices would trust it. Modern clients ignore the expired cross-sign and build a chain to ISRG Root X1 directly. Old clients, and specifically anything using OpenSSL 1.0.2 or earlier, try the expired path, fail, and stop. The result is certificate has expired for every Let’s Encrypt site, even though every one of those certificates is valid.

You are affected if any of these are true:

  • CentOS 7 / RHEL 7 that has not been updated since 2021 (ca-certificates before 2021.2.50)
  • Debian 9 or Ubuntu 16.04 without updates
  • Anything with OpenSSL 1.0.2 (openssl version)
  • macOS 10.12 or earlier, or an old Homebrew curl
  • An embedded device or router with a frozen CA bundle
  • A Docker image built years ago and never rebuilt

Fix, Debian / Ubuntu:

sudo apt-get update && sudo apt-get install --only-upgrade ca-certificates
sudo update-ca-certificates

Fix, RHEL / CentOS / Amazon Linux:

sudo yum update ca-certificates

Fix, Alpine: apk add --upgrade ca-certificates.

If you are stuck on OpenSSL 1.0.2 and cannot update it, remove the expired root so OpenSSL stops trying to use it. The current ca-certificates packages already drop it; if yours still lists it:

# Debian-family
sudo sed -i 's|^mozilla/DST_Root_CA_X3.crt|!mozilla/DST_Root_CA_X3.crt|' /etc/ca-certificates.conf
sudo update-ca-certificates
# RHEL-family
sudo trust anchor --remove /etc/pki/ca-trust/source/anchors/DST_Root_CA_X3.pem 2>/dev/null; sudo update-ca-trust

Confirm the fix with curl -sI https://letsencrypt.org | head -1.

4. Check which CA store your tool actually uses

curl on the command line uses the system store, but many tools bring their own, and updating the OS does not update them:

Tool Where its roots come from Fix
Python requests the certifi package pip install --upgrade certifi
Python urllib / ssl system store (or SSL_CERT_FILE) update OS bundle
Node.js roots compiled into Node upgrade Node, or NODE_EXTRA_CA_CERTS
Java $JAVA_HOME/lib/security/cacerts upgrade the JDK, or import the root with keytool
Go system store update OS bundle
PHP curl curl.cainfo in php.ini point it at a current bundle
Ruby system store or SSL_CERT_FILE update OS bundle
Homebrew curl openssl@3 keg’s bundle brew upgrade openssl ca-certificates
Git for Windows its own bundle reinstall Git, or http.sslBackend schannel

If curl works but your program does not, this table is the reason. Check SSL_CERT_FILE and CURL_CA_BUNDLE in the environment too; a stale bundle pinned by one of those variables overrides everything.

5. Check whether the server sends an expired intermediate

A server can hold a perfectly valid certificate and still send an expired intermediate alongside it. Clients that already know a newer path succeed; strict clients like curl fail. Look at the whole chain:

echo | openssl s_client -connect example.com:443 -servername example.com -showcerts 2>/dev/null \
  | awk '/BEGIN CERT/,/END CERT/' | openssl crl2pkcs7 -nocrl -certfile /dev/stdin \
  | openssl pkcs7 -print_certs -noout

Then check dates on each with openssl x509 -noout -dates. If an intermediate is expired, the server operator needs to reinstall the certificate with a current bundle. With certbot, serving fullchain.pem instead of cert.pem fixes it. For Let’s Encrypt specifically, a server can be told to use the short chain that skips the old cross-sign:

sudo certbot renew --preferred-chain "ISRG Root X1" --force-renewal

6. What not to do

curl -k (or --insecure, or verify=False in Python, or NODE_TLS_REJECT_UNAUTHORIZED=0) makes the error disappear by turning off certificate validation entirely. That means the connection will accept any certificate from anyone, which is fine for a one-off test against your own staging box and dangerous everywhere else, particularly in a script that ships secrets. If the problem is the CA bundle, fix the bundle. It takes one command.

Stop finding out from a failed job

If the expired certificate was your own, the question is why nobody knew it was about to expire. A cron job failing at 3am is a bad monitoring system. Check the served certificate on a schedule and alert on days remaining instead. SitesRadar’s free plan monitors one site’s SSL expiry, along with uptime, DNS, and broken links, and emails at 30, 14, and 7 days out. The full process for several sites is in SSL certificate expiration monitoring, and the list of ways “automatic” renewal fails is in why your SSL certificate expired without warning.

FAQ

It only fails inside Docker. Why? The image’s CA bundle is old, or the image has no bundle at all. Rebuild from a current base image, or add ca-certificates in the Dockerfile and run update-ca-certificates.

It fails in CI but works on my laptop. Same cause: the CI runner’s image is older, or a tool in the pipeline uses its own bundle. Check the runner’s OS and the tool table above.

Wget says “cannot verify … certificate has expired” too. Same fix. wget uses the system store on Linux and its own on Windows.

I updated ca-certificates and it still fails. Check the environment variables (SSL_CERT_FILE, CURL_CA_BUNDLE, REQUESTS_CA_BUNDLE), and check whether your tool bundles its own roots. Then check the clock.

← More from the SitesRadar blog