Your build failed. Or a cron job, or a pip install, or a git clone on a CI runner that worked yesterday. The error says a certificate has expired, and every search result is about clearing your browser cache.
Command-line tools and HTTP libraries do the same certificate validation a browser does, but they report failures in their own vocabulary and they fail hard — no click-through, no exception, just a non-zero exit code in the middle of a pipeline. The wording differs per tool. The underlying check does not.
Three different problems produce the expiry error, and they have three different fixes: the certificate on the server genuinely expired, your machine's clock is wrong, or your local CA bundle is too old to validate the chain. Confirm which one you have before you change anything. And if you already reached for curl -k or verify=False — that section is near the end, and it explains why the error going away is the worst possible outcome.
Read the Certificate Before You Read the Error
Every diagnosis in this post starts with the same command. Run it against the host your tool is failing to reach:
openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>/dev/null \
| openssl x509 -noout -subject -datesYou get three lines back:
subject=CN = example.com
notBefore=Apr 10 00:00:00 2026 GMT
notAfter=Jul 9 23:59:59 2026 GMTCompare notAfter against date -u, not against your wall clock. Certificate dates are always UTC, and a good half of the confusion in this area comes from comparing a UTC timestamp against a local one.
That comparison sorts your problem into one of four buckets:
- ✓
notAfteris in the past — the server certificate really has expired. Go to the section for your tool to confirm, then fix it at the source. - ✓
notBeforeis in the future — the certificate is not yet valid. That is almost never the server's fault. Your clock is wrong. - ✓Both dates bracket today, but your tool still fails — this is not an expiry problem at all. It is a chain, trust store, or hostname problem wearing the same error string, or your local CA bundle contains an expired root.
- ✓The command itself fails to connect — you have a network, DNS, or firewall problem, and TLS never started.
If you don't have a terminal on the machine that matters, the SSL checker performs the same handshake from outside your network and shows the expiry date and chain. That external view is also how you prove the certificate is fine and the problem is local — the single most useful fact you can establish early.
curl: SSL certificate problem: certificate has expired
The exact string, and the exit code that matters in a script:
$ curl https://example.com
curl: (60) SSL certificate problem: certificate has expired
More details here: https://curl.se/docs/sslcerts.htmlExit code 60 is curl's generic peer-certificate-cannot-be-authenticated code. It covers expiry, but it also covers several problems that are not expiry, and the text after the colon is what distinguishes them. Read it carefully:
- ✓
certificate has expired— a certificate in the presented chain is pastnotAfter. This is the expiry case. - ✓
unable to get local issuer certificate— curl cannot find the issuer. Incomplete chain on the server, or a missing root locally. Not expiry. - ✓
self-signed certificate in certificate chain— usually a corporate TLS-inspecting proxy. Not expiry. - ✓
SSL: no alternative certificate subject name matches— hostname mismatch. Not expiry.
To see the dates curl itself is reading, use verbose mode:
curl -vI https://example.com 2>&1 | grep -E "expire date|start date|subject:|issuer:"curl prints start date and expire date for the peer certificate during the handshake, before it decides to reject. If expire date is in the past, the diagnosis is settled. If it looks fine and curl still rejects, the expired certificate is somewhere else in the chain — most likely in your local trust store, which the stale CA bundle section covers.
One platform note: on macOS and Windows, curl may be built against the system trust store rather than a file on disk, so CURL_CA_BUNDLE and --cacert behave differently than they do on Linux. Check with curl -V and look at the SSL backend line.
Python: CERTIFICATE_VERIFY_FAILED certificate has expired
Through requests, the traceback ends like this:
requests.exceptions.SSLError: HTTPSConnectionPool(host='example.com', port=443):
Max retries exceeded with url: / (Caused by SSLError(SSLCertVerificationError(1,
'[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has
expired (_ssl.c:1006)')))Through urllib or the standard library:
urllib.error.URLError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify
failed: certificate has expired (_ssl.c:1006)Ignore the _ssl.c line number. It is a location in CPython's source, it changes between releases, and it tells you nothing about your problem. The load-bearing text is the clause after the second colon: certificate has expired. Compare that against what else can appear there:
- ✓
certificate has expired— expiry, in the server chain or in your trust store. - ✓
unable to get local issuer certificate— trust store or chain problem, not expiry. - ✓
self-signed certificate in certificate chain— interception proxy, not expiry. - ✓
Hostname mismatch, certificate is not valid for example.com— hostname, not expiry.
To read the dates from Python itself without validating anything, use ssl.get_server_certificate, which performs no verification by design:
python3 -c "import ssl; print(ssl.get_server_certificate(('example.com', 443)))" > cert.pem
openssl x509 -in cert.pem -noout -subject -datesIf those dates are current and Python still refuses, the problem is which trust store Python is using. This is the part that catches people, because Python does not have one answer:
python3 -c "import ssl, certifi; print(certifi.where()); print(ssl.get_default_verify_paths())"requests uses the certifi bundle shipped inside your virtualenv. The standard library ssl module uses the paths OpenSSL was compiled with, typically the system bundle. Those are two different files that can drift apart by years, which is why the same URL can succeed with urllib and fail with requests in the same interpreter. On macOS, a python.org installer build trusts neither until you run the Install Certificates.command script bundled with it.
The environment variables REQUESTS_CA_BUNDLE and SSL_CERT_FILE override all of this silently. If one of them points at a vendored bundle someone committed three years ago, every diagnosis above will mislead you. Check before you go further:
env | grep -iE "ca_bundle|cert_file|cert_dir"git: server certificate verification failed
git produces two different errors for the same condition, depending on which TLS library your git was built against. Debian and Ubuntu builds historically use GnuTLS:
fatal: unable to access 'https://example.com/repo.git/': server certificate
verification failed. CAfile: /etc/ssl/certs/ca-certificates.crt CRLfile: noneBuilds against OpenSSL pass curl's message straight through:
fatal: unable to access 'https://example.com/repo.git/': SSL certificate
problem: certificate has expiredThe GnuTLS variant is the harder one, because it never says why verification failed. Expiry, a missing root, and a hostname mismatch all produce that identical line. You cannot diagnose it from the message — run the openssl s_client check from the first section against the git host and read the dates yourself.
For more detail from git, turn on curl tracing:
GIT_TRACE_CURL=1 GIT_TRACE=1 git clone https://example.com/repo.gitOlder versions use GIT_CURL_VERBOSE=1 instead. Either way you get the same expire date line curl prints, which settles the question.
Then check what git is validating against, since git keeps its own configuration separate from curl's:
git config --get http.sslCAInfo
git config --get http.sslBackend
git versionOn CI images this is where the answer usually lives. A container built on a base image that pins http.sslCAInfo to a path shipped in the image will keep using that file forever, no matter how current the rest of the system is.
Node.js: CERT_HAS_EXPIRED
Node reports this as an error code rather than a sentence, which makes it easier to act on:
Error: certificate has expired
at TLSSocket.onConnectSecure (node:_tls_wrap:1674:34)
code: 'CERT_HAS_EXPIRED'Through npm:
npm ERR! code CERT_HAS_EXPIRED
npm ERR! request to https://registry.example.com/some-package failed,
reason: certificate has expiredNode's error codes come straight from OpenSSL's verification result, so each one maps to a specific failure and they are worth memorizing:
- ✓
CERT_HAS_EXPIRED— a certificate in the chain is past itsnotAfter. Expiry. - ✓
CERT_NOT_YET_VALID— pastnotBeforein the wrong direction. Almost always a clock problem. - ✓
UNABLE_TO_VERIFY_LEAF_SIGNATURE— the server sent an incomplete chain. Not expiry. - ✓
SELF_SIGNED_CERT_IN_CHAIN— an interception proxy or an internal CA you have not trusted. Not expiry. - ✓
ERR_TLS_CERT_ALTNAME_INVALID— hostname mismatch. Not expiry.
To read the dates Node sees, connect and inspect without validating:
node -e "
const tls = require('tls');
const s = tls.connect({ host: 'example.com', port: 443, servername: 'example.com', rejectUnauthorized: false }, () => {
const c = s.getPeerCertificate();
console.log('valid_from', c.valid_from);
console.log('valid_to ', c.valid_to);
s.end();
});
"That rejectUnauthorized: false is doing exactly what the trap section warns against — it is acceptable here because this snippet's only job is to print two dates and exit. It is a diagnostic, not a pattern to copy into application code.
Node bundles its own root store, compiled into the binary. It does not read the system trust store on Linux, which is why upgrading ca-certificates in a container fixes curl and Python while leaving Node broken. The legitimate lever is NODE_EXTRA_CA_CERTS, which appends a PEM file to the built-in roots:
NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt node app.jsThat adds trust. It never disables validation, which is what makes it a fix rather than a bypass.
Java: PKIX path validation failed
Java buries the real cause several exceptions deep, and the two most common stack traces look similar while meaning opposite things:
javax.net.ssl.SSLHandshakeException: PKIX path validation failed:
java.security.cert.CertPathValidatorException: validity check failed
Caused by: java.security.cert.CertificateExpiredException:
NotAfter: Thu Jul 09 23:59:59 UTC 2026That is expiry. NotAfter names the date, and CertificateExpiredException names the cause. Compare it against this one:
javax.net.ssl.SSLHandshakeException: PKIX path building failed:
sun.security.provider.certpath.SunCertPathBuilderException: unable to find
valid certification path to requested targetPath building failed means Java could not assemble a chain to a trusted root — a missing intermediate or an untrusted CA. Path validation failed means it built the chain and then rejected a certificate in it, usually on dates. One word apart, entirely different fixes. Do not update your truststore because you saw the word PKIX.
To see the handshake and the certificate dates Java is reading:
java -Djavax.net.debug=ssl:handshake -jar app.jarTo read the server certificate without writing any code:
keytool -printcert -sslserver example.com:443Java's truststore ships with the JDK, not with the operating system, so a container running an old JDK image carries whatever roots that JDK was released with. Updating the OS packages does nothing for it. Inspect the truststore directly:
keytool -list -v -keystore "$JAVA_HOME/lib/security/cacerts" -storepass changeit | grep -E "Alias name|Valid from"On Windows the same file lives under the JDK installation:
keytool -list -v -keystore "C:\Program Files\Java\jdk-21\lib\security\cacerts" -storepass changeitOlder JDK 8 layouts put it at jre\lib\security\cacerts instead. If keytool reports an alias whose Valid from window has already closed, you have found an expired root in your own truststore — the server certificate was never the problem.
OpenSSL s_client: sslv3 alert certificate expired
This one is worth reading slowly, because the error is almost always attributed to the wrong side of the connection.
$ openssl s_client -connect api.example.com:443 -cert client.crt -key client.key
...
40770000:error:0A000412:SSL routines:ssl3_read_bytes:sslv3 alert certificate
expired:ssl/record/rec_layer_s3.c:1590:SSL alert number 45The function name is ssl3_read_bytes. OpenSSL read that alert off the wire. It did not generate it. Alert number 45 is certificate_expired, and the server sent it — which means the server is complaining about the certificate you presented. This is a mutual TLS setup and your client certificate has expired, not the server's.
Contrast that with the ordinary case, where you are the one rejecting them. There s_client completes the handshake and tells you so in the summary block at the end:
Verify return code: 10 (certificate has expired)So the direction is decided by which of those two you see:
- ✓
Verify return code: 10— you validated the server's certificate and rejected it. Their certificate expired. The fix is on their side. - ✓
sslv3 alert certificate expiredread viassl3_read_bytes— the peer rejected the certificate you sent. Your client certificate expired. The fix is on your side. - ✓The same alert text via
ssl3_write_bytes— you sent the alert. You are the server in this exchange, and the client's certificate expired.
If it is your client certificate, check it directly — it is a file you own, so no handshake is needed:
openssl x509 -in client.crt -noout -subject -issuer -datesAnd ignore the string sslv3. It is a legacy label baked into OpenSSL's alert descriptions and has nothing to do with the protocol version in use. TLS 1.2 and TLS 1.3 connections emit that identical wording. Nobody is negotiating SSLv3, and disabling SSLv3 will not change anything.
The same underlying check appears in every other runtime with its own phrasing. If you arrived here from one of these, everything in this post still applies:
- ✓Go —
x509: certificate has expired or is not yet valid: current time 2026-08-18T09:14:22Z is after 2026-07-09T23:59:59Z. Go prints both timestamps, which makes it the fastest runtime for spotting a clock problem. - ✓Ruby —
OpenSSL::SSL::SSLError: SSL_connect returned=1 errno=0 state=error: certificate verify failed (certificate has expired) - ✓PHP —
cURL error 60: SSL certificate problem: certificate has expired - ✓.NET —
The remote certificate is invalid because of errors in the certificate chain: NotTimeValid - ✓Rust —
invalid peer certificate: Expired
When the Certificate Is Fine and Your Clock Is Wrong
Certificate validation is a comparison against the local system time. If that time is wrong, valid certificates fail and there is nothing wrong with the certificate at all.
The tells:
- ✓The error says not yet valid, or Go prints a
current timethat is obviously wrong. - ✓The same URL works from a different machine on the same network.
- ✓
openssl s_clientshows anotAftercomfortably in the future and the tool rejects it anyway.
Check first, in UTC:
date -u
timedatectl statusThe environments where this actually happens are specific, and containers dominate the list:
- ✓A Docker Desktop VM whose clock drifted while the laptop was asleep. The container inherits the host VM's time, and it can be hours or days behind after a long suspend.
- ✓A WSL2 instance after hibernation, which historically drifts from the Windows host clock.
- ✓A virtual machine restored from a snapshot taken months ago.
- ✓Hardware with a dead RTC battery, which boots to the epoch or to the firmware's build date.
- ✓An air-gapped or egress-filtered CI runner that cannot reach an NTP server, so nothing ever corrects it.
The fixes are per-platform:
sudo timedatectl set-ntp trueFor Docker Desktop, restart the VM rather than the container — the container has no clock of its own. For WSL2, run wsl --shutdown from Windows and restart the distribution. On Windows itself, w32tm /resync from an elevated prompt.
A clock set forward has the mirror-image effect: perfectly valid certificates read as expired, and you will spend the afternoon renewing a certificate that had four months left. Confirm the clock before you touch the certificate.
When the CA Bundle Is Stale
This is the cause nobody looks for, and it produces the most confusing symptom in this post: the failure reproduces only inside the container, or only on the CI runner, and never on your laptop.
Here is why the error is misleading. Your tool reports certificate has expired when any certificate in the validated chain is past its dates — and the chain includes the root certificate from your own local trust store. If that root expired, the server's leaf certificate can have eleven months of validity left and you will still be told a certificate has expired. Nothing in the message distinguishes their leaf from your root.
The canonical example is DST Root CA X3, which expired on 30 September 2021. Overnight, clients with old CA bundles started failing against Let's Encrypt sites whose certificates were entirely valid. Every one of those failures reported an expired certificate. Every one of them was fixed on the client, not the server.
The tell is environmental rather than textual:
- ✓It fails in the container and works on the host, against the same URL, at the same moment.
- ✓It started failing without anyone touching the server.
- ✓It fails in an old base image and works in a current one.
To see the whole chain rather than just the leaf, split the presented certificates and read each one:
openssl s_client -connect example.com:443 -servername example.com -showcerts < /dev/null 2>/dev/null > chain.txt
awk '/-----BEGIN CERTIFICATE-----/{n++} n>0{print > ("cert-" n ".pem")}' chain.txt
for f in cert-*.pem; do openssl x509 -in "$f" -noout -subject -enddate; echo; doneThat covers what the server sent. To check the root your own machine is supplying, run the handshake inside the failing environment and read the verify line:
docker run --rm your-image openssl s_client -connect example.com:443 -servername example.com < /dev/null 2>&1 | grep "Verify return code"If that returns code 10 inside the container while the same command returns code 0 on your host, the certificate is fine and your image's trust store is not.
The fix depends on which store the failing tool actually reads, and they are all separate:
- ✓Debian and Ubuntu —
apt-get update && apt-get install --reinstall -y ca-certificates - ✓Alpine —
apk add --no-cache ca-certificates && update-ca-certificates - ✓Python requests —
pip install --upgrade certifi, since it uses its own bundle rather than the system one - ✓Node.js — the roots are compiled into the binary, so upgrade Node itself or supply
NODE_EXTRA_CA_CERTS - ✓Java — the roots ship in the JDK's
cacerts, so upgrade the JDK image or import the root withkeytool -importcert - ✓Go — uses the system bundle on Linux, so the operating system package fix applies
Two things will undo all of the above. First, a cached Docker layer: if the RUN apt-get install ca-certificates line has not changed, Docker reuses the layer it built months ago and installs nothing. Build that step with --no-cache or move to a newer base image tag. Second, a vendored bundle wired in through SSL_CERT_FILE, SSL_CERT_DIR, CURL_CA_BUNDLE, or REQUESTS_CA_BUNDLE — these override the system store completely, and a .pem file someone committed to the repo in 2019 will keep being trusted as the only source of truth no matter how many packages you upgrade.
When It Is the Remote Server, Not You
Sometimes the diagnosis comes back clean and the answer is simply that somebody else's certificate expired. An API you depend on, a package registry, an internal service owned by another team.
Establish it properly before you accept it, because the conclusion determines what you do next:
- ✓Their
notAfteris in the past, confirmed from the SSL checker or from another machine outside your network. - ✓Your clock is correct in UTC.
- ✓Your CA bundle is current, verified against a host that still works.
- ✓Other clients on other networks fail the same way.
What you can actually do about it:
- ✓Report it to whoever owns the host. For an internal service that is a person you can reach in minutes, and it is usually the fastest fix available to you. For a vendor, their status page and support channel.
- ✓Use a mirror or a cache. Package registries have them, and a build that pulls from a cached artifact never touches the origin certificate.
- ✓Use the copy you already have. If a dependency is in your lockfile and your local or CI cache, pin to it and let the build proceed without a fresh fetch.
- ✓Fail gracefully and retry. If it is a runtime dependency rather than a build one, an expired upstream certificate is an outage on their side. Degrade, queue, retry with backoff. Treat it the way you would treat a 503.
What you cannot responsibly do is trust it anyway. Their certificate expiring is a temporary outage on their side. Disabling verification in your code converts it into a permanent vulnerability on yours — one that outlives their fix by years, because nothing will ever remind you to undo it.
If you genuinely cannot wait, the narrowest defensible shape is a one-off command scoped to that single host, run interactively, never committed, and never an environment variable. Even then you are moving their expired certificate onto your own risk register, and you should say so out loud to whoever owns the system.
The Trap: Turning Verification Off
Every tool in this post has a flag that makes the error disappear instantly:
curl -k https://example.com
git config --global http.sslVerify false
export NODE_TLS_REJECT_UNAUTHORIZED=0
requests.get(url, verify=False)
export PYTHONHTTPSVERIFY=0Java's version is worse, because it takes a custom TrustManager whose checkServerTrusted method does nothing — twenty lines of code that read like real engineering and disable the entire certificate system.
These are debugging steps. They are never fixes. They do not repair the certificate, they do not renew anything, and they do not make the connection safe. They tell your tool to stop checking, so from that moment on an expired certificate, a forged certificate, and a valid one all look identical to your code.
Four specific reasons this is worse than it looks:
- ✓The scope is wrong.
NODE_TLS_REJECT_UNAUTHORIZED=0andgit config --global http.sslVerify falseapply to every host your process or user ever contacts, not the one that was broken. One expired certificate on a staging box disables TLS validation for your package registry, your secrets backend, and your production API. - ✓They persist. These lines get committed. They land in Dockerfiles, CI YAML, and base images, and they get inherited by services written years later by people who never saw the original outage. A
verify=Falsein a utility function is permanent by default. - ✓They silence the alarm. After the flag goes in, nothing in your stack will ever report a certificate problem again — not the next expiry, not a misissued certificate, not an actual interception. You have not fixed the failure, you have removed your ability to detect the entire class of failure.
- ✓The real cause survives. If it was system time or a stale bundle, the bypass hides a broken environment that will keep producing wrong results in other ways, and TLS validation was the only thing catching it.
Use them to answer exactly one question — is this really a certificate problem? — and remove them in the same session. If a bypass survives past the diagnosis, you no longer have an expired certificate. You have an unmonitored one.
If the Certificate Is Yours, Fix It at the Source
Every diagnosis above ends in the same place when the expired certificate is on a server you control: renew it, install it, and reload the service.
certbot renew --force-renewal
systemctl reload nginxThe reload is not optional, and it is the step that gets skipped. A renewed certificate sitting on disk changes nothing until the process serving TLS re-reads it, and a long-running server will happily keep presenting the expired one from memory for weeks. Confirm from outside afterward with the openssl s_client command from the first section — check what is actually being served, not what is on the filesystem.
The full renewal walkthrough, including manually managed certificates from a CA dashboard, origin certificates behind Cloudflare, and the specific ways auto-renewal fails silently, is in how to fix an expired SSL certificate. If certbot itself is failing rather than the certificate simply being old, why Let's Encrypt auto-renewal fails covers each failure mode individually.
Stop Finding Out From a Failed Build
A failing pipeline is a bad certificate monitor. It fires after the expiry rather than before it, and it reports the problem in whichever dialect the tool that happened to break speaks first — which is how a routine expiry turns into an afternoon of reading OpenSSL alert numbers.
The expiry date was knowable for the entire life of the certificate. It is printed inside the certificate, it does not move, and the only reason it surprised anyone is that nothing was watching it.
External monitoring checks the certificate actually being served, from outside your infrastructure, on a daily schedule. It catches the renewal that ran but never reloaded, and it catches the certificate on a service nobody remembers owning — with enough lead time to renew during working hours instead of mid-deploy.
ExpiryPing runs that check daily for every domain you add, over a live TLS handshake, and alerts you at 30, 14, 7, and 1 day. No agent, no credentials, no access to your servers. Free for up to 3 domains, paid plans from $19/month.
The certificate that broke your build today was going to expire on that date regardless. The only variable was whether you found out from a calendar or from a stack trace.