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-certificatesbefore2021.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.