How to Diagnose and Fix SSL/TLS Certificate Errors

Type: Software Reference Confidence: 0.95 Sources: 8 Verified: 2026-02-23 Freshness: quarterly

TL;DR

Constraints

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.comsub.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, currently 90-day certificates (moving to 45-day by 2028). [src4, src5, src7]

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

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

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 now; 45-day certs by Feb 2028 [src4, src7]
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) → 100 days (2027) → 47 days (2029) [src6]
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

Related Units