How to Diagnose and Fix SSL/TLS Certificate Errors
How do I diagnose and fix SSL/TLS certificate errors?
TL;DR
- Bottom line: SSL/TLS errors fall into 5 main categories: expired certificate, untrusted
CA (including self-signed), hostname mismatch, incomplete chain, and protocol/cipher mismatch. First
diagnostic:
openssl s_client -connect host:443 -servername host— shows the full chain, expiry, and whether verification succeeded. - Key tool/command:
openssl s_client -connect example.com:443 -servername example.com— shows certificate chain, expiry date, issuer, and verification result. - Watch out for: Incomplete certificate chains (missing intermediate certs) — the #1 cause of "works in Chrome but fails in curl/Python/Java". Servers must send the full chain.
- Works with: All TLS implementations (OpenSSL, BoringSSL, NSS, SChannel). Applies to HTTPS, SMTP/STARTTLS, LDAPS, any TLS-secured protocol.
- 2026 update: 200-day max validity is LIVE — DigiCert enforced 199 days on
2026-02-24, Sectigo on 2026-03-12, GlobalSign on 2026-03-14. Drops to 100 days (2027) and 47 days
(2029). Let's Encrypt 6-day certs are GA (Jan 2026,
shortlivedACME profile); thetlsserverprofile cuts over to 45-day certs on 2026-05-13. ACME automation is mandatory. [src6, src7, src9, src10]
Constraints
- Never disable certificate verification in production:
verify=False(Python),-k(curl),rejectUnauthorized: false(Node.js) all disable TLS security and enable MITM attacks. Fix the underlying cert issue instead. [src1, src2] - TLS 1.2 is the minimum: TLS 1.0 and 1.1 are deprecated since 2020 and blocked by Chrome 84+, Firefox 78+, Safari 14+. Do not enable them as a "fix". [src2]
- Always send the full certificate chain: Servers must include leaf + intermediate(s). Chrome can auto-fetch missing intermediates via AIA, but curl, Python, Java, Go, and other non-browser clients cannot. [src1, src3]
- Wildcard certs have limits:
*.example.comcoverswww.example.combut NOTexample.com(apex) and NOTsub.api.example.com(multi-level). Use multi-SAN certs. [src3, src8] - Certificate validity is shrinking — 200-day cap is now live: As of 2026-03-15 the CA/B Forum cap is 200 days; DigiCert/Sectigo/GlobalSign all issue at 199 days (one-day buffer for time-zone safety). Validity drops to 100 days (March 2027) and 47 days (March 2029) per ballot SC-081. Manual renewal is no longer viable. [src6, src9]
- ClientAuth EKU removal: From June 2026, Chrome will not trust new public TLS certs with the ClientAuth EKU. Use private CAs for mutual TLS / client certificate authentication. [src6]
Quick Reference
| # | Error | Browser Message | Root Cause | Fix |
|---|---|---|---|---|
| 1 | Expired certificate | ERR_CERT_DATE_INVALID | Past notAfter date |
Renew; automate with Let's Encrypt [src1, src4] |
| 2 | Untrusted CA | ERR_CERT_AUTHORITY_INVALID | CA not in trust store | Use trusted CA; install intermediate certs [src1, src3] |
| 3 | Self-signed | NET::ERR_CERT_INVALID | No CA chain | Use Let's Encrypt for production [src1, src7] |
| 4 | Hostname mismatch | ERR_CERT_COMMON_NAME_INVALID | CN/SAN doesn't match domain | Reissue cert with correct SANs [src1, src3] |
| 5 | Incomplete chain | ERR_CERT_AUTHORITY_INVALID | Missing intermediate cert | Install full chain on server [src1, src3] |
| 6 | Protocol mismatch | ERR_SSL_VERSION_OR_CIPHER_MISMATCH | TLS 1.0/1.1 only; no shared cipher | Enable TLS 1.2/1.3 on server [src2, src6] |
| 7 | Certificate revoked | ERR_CERT_REVOKED | CA revoked the cert (OCSP/CRL) | Reissue certificate [src1, src8] |
| 8 | Pinning failure | ERR_SSL_PINNED_KEY_NOT_IN_CERT_CHAIN | HPKP/cert pinning mismatch | Update pinned key or wait for pin expiry [src2] |
| 9 | Mixed content | Mixed Content warning | HTTPS page loads HTTP resources | Change all resource URLs to HTTPS [src1, src2] |
| 10 | Clock skew | ERR_CERT_DATE_INVALID | Client clock wrong | Sync system clock (NTP) [src1, src4] |
| 11 | Wildcard mismatch | ERR_CERT_COMMON_NAME_INVALID | *.example.com ≠ sub.sub.example.com |
Use multi-SAN cert [src3, src8] |
Decision Tree
START — SSL/TLS error
├── openssl s_client -connect host:443 -servername host
│ ├── "certificate has expired" → renew cert; check client clock [src4]
│ ├── "unable to get local issuer certificate" → incomplete chain [src1, src3]
│ ├── "self signed certificate" → replace with CA-signed cert [src1, src7]
│ ├── "hostname mismatch" → reissue cert with correct SANs [src3]
│ ├── "no shared cipher" → enable TLS 1.2/1.3 on server [src2, src6]
│ └── "certificate has been revoked" → reissue from CA [src1, src8]
└── Works in browser but fails in code?
├── Python → check certifi; use ssl.create_default_context() [src2]
├── Java → import cert into JVM truststore (keytool) [src2]
├── curl → use --cacert or update ca-certificates [src2]
├── Node.js → set NODE_EXTRA_CA_CERTS [src2]
└── Go → add cert to system trust store or custom tls.Config [src2]
Step-by-Step Guide
1. Inspect the certificate with openssl
The single most powerful diagnostic tool for any TLS issue. [src1, src3, src5]
# Full connection inspection
openssl s_client -connect example.com:443 -servername example.com
# Check certificate dates
openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates
# Check Subject Alternative Names
openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -text | grep -A5 "Subject Alternative Name"
# Verify certificate chain
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt fullchain.pem
# Check TLS version support
openssl s_client -tls1_2 -connect example.com:443 -servername example.com
openssl s_client -tls1_3 -connect example.com:443 -servername example.com
Verify: openssl s_client ... | grep "Verify return code" → expected:
Verify return code: 0 (ok)
2. Fix expired certificates with Let's Encrypt
Free, automated. Default classic profile is 90-day certs today, dropping to 64 days on 2027-02-10. The
tlsserver profile switches to 45-day certs on 2026-05-13. A 6-day shortlived
profile (160-hour validity) is GA (Jan 2026) for users who want the strongest security — renew every
2–3 days. [src4, src5, src7, src10]
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-run
sudo systemctl enable certbot.timer
# Opt into 6-day shortlived certs via ACME profile (certbot 4.0+)
sudo certbot --nginx -d example.com --preferred-profile shortlived
Verify: sudo certbot certificates → shows domain, expiry date, and certificate
path
3. Fix incomplete certificate chains
Missing intermediates are the #1 silent failure. [src1, src3]
# Check chain count (should be 2+)
openssl s_client -connect host:443 -servername host 2>/dev/null | grep -c "BEGIN CERTIFICATE"
# Build full chain
cat certificate.pem intermediate.pem > fullchain.pem
# nginx config
ssl_certificate /etc/ssl/certs/fullchain.pem;
ssl_certificate_key /etc/ssl/private/example.com.key;
Verify: grep -c "BEGIN CERTIFICATE" → expected: 2 or
3
4. Fix hostname mismatches
Certificate must include all hostnames in SANs. [src1, src3]
# Check what hostnames the cert covers
openssl x509 -noout -text -in cert.pem | grep -A2 "Subject Alternative Name"
# Reissue with Let's Encrypt — add all domains
sudo certbot --nginx -d example.com -d www.example.com -d api.example.com
Verify: openssl x509 ... | grep -A2 "Subject Alternative Name" → shows all
expected domains
5. Fix self-signed certs in code (development)
Trust a specific CA cert rather than disabling verification. [src1, src2]
# curl — trust specific CA
curl --cacert /path/to/ca.pem https://internal.example.com
# Add CA to system trust store (Ubuntu/Debian)
sudo cp ca.pem /usr/local/share/ca-certificates/my-ca.crt
sudo update-ca-certificates
Verify: curl --cacert /path/to/ca.pem https://internal.example.com → no SSL
errors
6. Fix TLS protocol/cipher mismatches
Enable TLS 1.2/1.3; disable TLS 1.0/1.1. [src2, src6]
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
add_header Strict-Transport-Security "max-age=63072000" always;
Verify: openssl s_client -tls1_3 -connect host:443 → expected:
Protocol : TLSv1.3
Code Examples
Comprehensive SSL certificate diagnostic script
#!/bin/bash
HOST="$1"; PORT="${2:-443}"
[ -z "$HOST" ] && echo "Usage: $0 <hostname> [port]" && exit 1
echo "=== SSL/TLS Diagnostic: $HOST:$PORT ==="
CERT=$(echo | openssl s_client -connect "$HOST:$PORT" -servername "$HOST" 2>/dev/null)
CERT_PEM=$(echo "$CERT" | openssl x509 2>/dev/null)
echo "=== Certificate Details ==="
echo "$CERT_PEM" | openssl x509 -noout -subject -issuer -dates 2>/dev/null
echo "=== SANs ==="
echo "$CERT_PEM" | openssl x509 -noout -text 2>/dev/null | grep -A3 "Subject Alternative Name" | tail -2
echo "=== Chain Count ==="
CHAIN=$(echo "$CERT" | grep -c "BEGIN CERTIFICATE")
echo " $CHAIN certificate(s) in chain $([ "$CHAIN" -lt 2 ] && echo '⚠️ may be incomplete')"
echo "=== Expiry ==="
NOT_AFTER=$(echo "$CERT_PEM" | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
echo " Expires: $NOT_AFTER"
echo "=== Verification ==="
echo "$CERT" | grep "Verify return code"
echo "=== TLS Support ==="
for VER in tls1_2 tls1_3; do
echo -n " $VER: "
echo | openssl s_client -"$VER" -connect "$HOST:$PORT" -servername "$HOST" 2>&1 \
| grep -E "Protocol|handshake failure" | head -1
done
Python — SSL certificate monitor
#!/usr/bin/env python3
import ssl, socket, datetime
def check_ssl_cert(hostname: str, port: int = 443) -> dict:
context = ssl.create_default_context()
try:
with socket.create_connection((hostname, port), timeout=10) as sock:
with context.wrap_socket(sock, server_hostname=hostname) as ssock:
cert = ssock.getpeercert()
tls_version = ssock.version()
except ssl.SSLCertVerificationError as e:
return {"error": str(e), "hostname": hostname}
except Exception as e:
return {"error": str(e), "hostname": hostname}
not_after = datetime.datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z")
days_left = (not_after - datetime.datetime.utcnow()).days
sans = [v for t, v in cert.get("subjectAltName", []) if t == "DNS"]
return {
"hostname": hostname,
"days_until_expiry": days_left,
"sans": sans,
"tls_version": tls_version,
"status": "ok" if days_left > 0 else "expired",
}
def monitor_certs(hostnames):
for host in hostnames:
r = check_ssl_cert(host)
if "error" in r:
print(f"FAIL {host}: {r['error']}")
elif r["days_until_expiry"] < 0:
print(f"EXPIRED {host}: {abs(r['days_until_expiry'])} days ago!")
elif r["days_until_expiry"] < 14:
print(f"CRITICAL {host}: {r['days_until_expiry']} days left")
elif r["days_until_expiry"] < 30:
print(f"WARNING {host}: {r['days_until_expiry']} days left")
else:
print(f"OK {host}: {r['days_until_expiry']} days, {r['tls_version']}")
monitor_certs(["example.com", "api.example.com"])
Let's Encrypt automation with Certbot
#!/bin/bash
DOMAIN="$1"; EMAIL="$2"; EXTRA="${3:-}"
[ -z "$DOMAIN" ] || [ -z "$EMAIL" ] && echo "Usage: $0 <domain> <email> [extra-domains]" && exit 1
command -v certbot &>/dev/null || apt-get install -y certbot python3-certbot-nginx
DOMAIN_ARGS="-d $DOMAIN"
for d in $EXTRA; do DOMAIN_ARGS="$DOMAIN_ARGS -d $d"; done
certbot --nginx --non-interactive --agree-tos --email "$EMAIL" $DOMAIN_ARGS --redirect
certbot renew --dry-run
# Auto-renewal cron (if systemd timer not available)
systemctl is-active certbot.timer &>/dev/null || \
(crontab -l 2>/dev/null; echo "0 3 * * * certbot renew --quiet --post-hook 'systemctl reload nginx'") | crontab -
echo "Certificate issued for $DOMAIN — renewal is automatic"
Anti-Patterns
Wrong: Disabling certificate verification in production
# ❌ BAD — disables all TLS security [src1, src2]
import requests
response = requests.get('https://api.example.com', verify=False)
# curl equivalent: curl -k https://api.example.com
Correct: Fix the actual certificate issue
# ✅ GOOD — trust a specific CA cert [src1, src2]
import requests
# Public CA (default)
response = requests.get('https://api.example.com')
# Private/internal CA
response = requests.get('https://api.example.com', verify='/path/to/ca-bundle.pem')
Wrong: Serving only the leaf certificate
# ❌ BAD — missing intermediate cert [src1, src3]
ssl_certificate /etc/ssl/certs/example.com.crt; # Just the leaf
# Works in Chrome (downloads via AIA), fails in curl/Python/Java
Correct: Serve the full certificate chain
# ✅ GOOD — full chain: leaf + intermediate(s) [src1, src3]
ssl_certificate /etc/ssl/certs/fullchain.pem;
ssl_certificate_key /etc/ssl/private/example.com.key;
# Build: cat example.com.crt intermediate.crt > fullchain.pem
Wrong: Using wildcard cert for multi-level subdomains
# ❌ BAD — *.example.com does NOT cover sub.api.example.com [src3, src8]
Certificate SANs: DNS:*.example.com
Accessing: https://sub.api.example.com
→ ERR_CERT_COMMON_NAME_INVALID
Correct: Use multi-SAN certificate
# ✅ GOOD — explicitly list all needed subdomains [src3, src8]
certbot --nginx -d example.com -d www.example.com -d api.example.com -d sub.api.example.com
Wrong: Ignoring certificate validity timeline changes
# ❌ BAD — assuming 398-day certs are still available after March 2026 [src6]
# Buying a 1-year cert after March 15, 2026 → max 200 days, not 398
# Manual renewal will not scale with 47-day certs by 2029
Correct: Automate certificate lifecycle with ACME
# ✅ GOOD — fully automated certificate management [src6, src7]
sudo certbot --nginx -d example.com --non-interactive --agree-tos --email [email protected]
sudo systemctl enable certbot.timer
# ACME Renewal Information (ARI) supported by certbot 2.8+
Common Pitfalls
- Incomplete chain is the #1 silent failure: Chrome downloads missing intermediates via AIA, so it "works" in Chrome but fails in curl, Python, Java, and older clients. Always serve the full chain. [src1, src3]
- Let's Encrypt lifetime reduction is happening now: The
tlsserverACME profile starts issuing 45-day certs on 2026-05-13; the default classic profile drops to 64 days on 2027-02-10. A 6-dayshortlivedprofile (160-hour validity) went GA on 2026-01-15 for users who want maximum security (renew every 2–3 days). Use ACME Renewal Information (ARI, certbot 2.8+) for optimal timing. Manual renewal will not scale. [src7, src10] - Clock skew causes false expiry errors: If the client's clock is wrong, valid
certificates appear expired. Always check
dateon the client before debugging the cert. [src1, src4] - SNI required for multi-domain servers: Always pass
-servernametoopenssl s_client. Without it, you may get the wrong certificate. [src2, src3] - HSTS prevents HTTP fallback: Once a browser has seen
Strict-Transport-Security, it refuses HTTP for that domain. If your cert breaks, users can't bypass it. Test HSTS carefully before setting longmax-age. [src2] - Wildcard certs don't cover the apex:
*.example.comcoverswww.example.combut NOTexample.comitself. Include both in your SAN list. [src3, src8] - ClientAuth EKU removal (June 2026): Chrome will stop trusting public TLS certs with ClientAuth EKU. Switch to private CAs for mutual TLS. [src6]
- 200-day max validity is LIVE (March 2026): Major CAs have already enforced the cap — DigiCert on 2026-02-24, Sectigo on 2026-03-12, GlobalSign on 2026-03-14. They issue at 199 days (not 200) as a time-zone safety buffer. Existing 398-day certs remain valid until natural expiry. Plan for ~2× more frequent renewals or automate with ACME. [src6, src9]
Diagnostic Commands
# Full TLS inspection
openssl s_client -connect host:443 -servername host
# Certificate dates
openssl s_client -connect host:443 -servername host 2>/dev/null | openssl x509 -noout -dates
# Subject Alternative Names
openssl s_client -connect host:443 -servername host 2>/dev/null \
| openssl x509 -noout -text | grep -A3 "Subject Alternative Name"
# Chain count (should be 2+)
openssl s_client -connect host:443 -servername host 2>/dev/null | grep -c "BEGIN CERTIFICATE"
# Verify chain
openssl verify -CAfile /etc/ssl/certs/ca-certificates.crt fullchain.pem
# TLS version testing
openssl s_client -tls1_2 -connect host:443 -servername host
openssl s_client -tls1_3 -connect host:443 -servername host
# curl verbose
curl -v https://host 2>&1 | grep -E "SSL|TLS|certificate|expire"
curl --cacert /path/to/ca.pem https://internal-host
# Days until expiry
echo | openssl s_client -connect host:443 -servername host 2>/dev/null \
| openssl x509 -noout -enddate | cut -d= -f2
# OCSP revocation check
openssl s_client -connect host:443 -servername host -status 2>/dev/null | grep -A3 "OCSP"
# Online tools:
# SSL Labs: https://www.ssllabs.com/ssltest/
# Chain check: https://whatsmychaincert.com/
# CT logs: https://crt.sh/
Version History & Compatibility
| Version / Standard | Status | Notes |
|---|---|---|
| TLS 1.3 (RFC 8446, 2018) | Current best | Faster handshake; forward secrecy mandatory; 93% of Cloudflare traffic (2025) [src2] |
| TLS 1.2 (RFC 5246, 2008) | Required minimum | Widely supported; still secure with modern ciphers [src2] |
| TLS 1.1 (RFC 4346, 2006) | Deprecated | Disabled in Chrome 84+, Firefox 78+, Safari 14+ [src2] |
| TLS 1.0 (RFC 2246, 1999) | Deprecated | Disabled in all modern browsers (2020) [src2] |
| SSL 3.0 | Broken (POODLE) | Never use; disabled everywhere [src2] |
| Let's Encrypt (2015+) | Free, automated | 90-day certs default; 6-day shortlived profile GA Jan 2026; tlsserver →
45-day on 2026-05-13; classic → 64-day on 2027-02-10 [src4,
src7,
src10] |
| Chrome 58+ (2017) | CN deprecated | Only checks SANs, ignores Common Name [src3] |
| Chrome 80+ (2020) | TLS 1.0/1.1 blocked | ERR_SSL_VERSION_OR_CIPHER_MISMATCH for old TLS [src2] |
| CA/B Forum SC-081 (2025) | Validity shrinking | 200 days (Mar 2026, LIVE) → 100 days (2027) → 47 days (2029) [src6] |
| DigiCert / Sectigo / GlobalSign (Feb–Mar 2026) | 199-day cap enforced | DigiCert 2026-02-24, Sectigo 2026-03-12, GlobalSign 2026-03-14 — all issue at 199 days for time-zone safety [src9] |
| Chrome 138+ (June 2026) | ClientAuth EKU removed | Public TLS certs can no longer support client authentication [src6] |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Browser shows certificate error | CORS error (different issue) | Fix CORS headers on server |
openssl s_client shows verify error |
Connection refused (port closed) | Check firewall/service |
| Certificate expired or expiring | DNS resolution failure | Fix DNS records |
| Hostname mismatch in error | HTTP 4xx/5xx errors | Debug application logic |
| "Works in browser, fails in code" | WebSocket errors after handshake | Debug application-level WS protocol |
Important Caveats
- Never use
verify=Falseor-kin production: It disables all TLS security and makes MITM attacks trivial. Fix the underlying certificate issue instead. [src1, src2] - Certificate Transparency (CT) logs: Since 2018, all publicly-trusted CAs must log certificates to CT logs. Certificates not in CT logs are rejected by Chrome. Use crt.sh to search CT logs. [src8]
- OCSP Must-Staple: If your certificate has the
must-stapleextension, your server must provide a valid OCSP staple or clients will reject the connection. [src8] - Private key security: The private key must never be shared or committed to version control. If compromised, revoke immediately via your CA and reissue. [src1]
- CAA DNS records: Add
CAADNS records to restrict which CAs can issue certificates for your domain. Example:example.com. CAA 0 issue "letsencrypt.org". [src8] - Java uses its own truststore: Java applications use
$JAVA_HOME/lib/security/cacerts, not the OS trust store. Add custom CAs withkeytool -importcert. [src2] - Certificate validity timeline (2026-2029): The 200-day cap is now active
(DigiCert 2026-02-24, Sectigo 2026-03-12, GlobalSign 2026-03-14 all issue at 199 days). CA/B Forum
ballot SC-081 mandates further drops to 100 days from March 2027 and 47 days from March 2029. Let's
Encrypt's
tlsserverprofile cuts to 45-day certs on 2026-05-13; the default classic profile drops to 64 days on 2027-02-10. Ashortlived6-day profile is GA for opt-in users. Plan accordingly. [src6, src7, src9, src10]