Defaults.Exposed

TLS and certificates: every browser warning and what causes it

A visitor sends you a screenshot. Red triangle, “Your connection is not private”, a code in small grey capitals such as NET::ERR_CERT_DATE_INVALID, and a “Back to safety” button they pressed. They did not read the fine print or click “Proceed anyway”. They left, and you found out because one person out of however many bothered to tell you.

Every one of those warnings has an exact cause, and the code tells you which one. Find your code in the table and jump to its section, where one command confirms it. Most fixes take under fifteen minutes once you know where to look.

Find your code first

The visitor sees (Chrome, Edge, Brave)Firefox sayscurl saysCause
NET::ERR_CERT_DATE_INVALIDSEC_ERROR_EXPIRED_CERTIFICATEcurl: (60) SSL certificate problem: certificate has expiredThe certificate ran out, or the visitor’s clock is wrong
NET::ERR_CERT_COMMON_NAME_INVALIDSSL_ERROR_BAD_CERT_DOMAINcurl: (60) SSL: no alternative certificate subject name matches target host nameThe certificate is for a different name than the one in the address bar
NET::ERR_CERT_AUTHORITY_INVALIDSEC_ERROR_UNKNOWN_ISSUER or MOZILLA_PKIX_ERROR_SELF_SIGNED_CERTcurl: (60) SSL certificate problem: unable to get local issuer certificateSelf-signed, or the intermediate certificate is missing from the chain
ERR_SSL_VERSION_OR_CIPHER_MISMATCHSSL_ERROR_NO_CYPHER_OVERLAPcurl: (35) error:0A000410:SSL routines::sslv3 alert handshake failureThe server only offers protocols or ciphers the browser refuses
ERR_SSL_PROTOCOL_ERRORSSL_ERROR_RX_RECORD_TOO_LONGcurl: (35) error:0A00010B:SSL routines::wrong version numberPort 443 is answering with something that is not TLS
NET::ERR_CERT_REVOKEDSEC_ERROR_REVOKED_CERTIFICATE(curl does not check revocation by default)The issuing authority revoked the certificate
“Not secure” in the address bar, page loads anywayPadlock with a warning trianglenothingPlain HTTP, or an HTTPS page pulling in HTTP resources

The command behind the curl column is the same every time, so run it once against your own site and keep the output:

curl -sSI https://www.example.com/ -o /dev/null

If it prints nothing, curl was happy, and the problem is either specific to one browser or has already fixed itself. If it prints a line starting curl: (60) or curl: (35), you have your row.

NET::ERR_CERT_DATE_INVALID: it expired

This is the one that arrives at 09:04 on a Monday with a queue of complaints behind it. The certificate had a notAfter date, the date passed, and every browser on earth started refusing it within the same minute. Certificates from the big automated issuers now run 90 days, so anything renewed by hand, or by a cron job that stopped, hits this on a predictable schedule.

Confirm it in one line:

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

You get back two lines, notBefore= and notAfter=. If notAfter is in the past, that is the whole diagnosis. If notAfter is in the future and the visitor still sees NET::ERR_CERT_DATE_INVALID, their device clock is wrong, which happens on laptops with a dead CMOS battery and on phones that never synced after a factory reset. You cannot fix that from your side; tell them to check the date.

The fix for a real expiry depends on how the certificate got there. If it was Let’s Encrypt via certbot, run sudo certbot renew and then sudo systemctl reload nginx (or apache2). Certbot renews the file on disk; it does not always tell the web server to load the new one, and a stale process serving an old certificate from memory looks identical to a failed renewal.

If a hosting panel issued it, look for the auto-renew toggle and check it is still on.

Then find out why it lapsed. sudo systemctl list-timers | grep certbot should show a timer that ran within the last day. If it is absent, nobody automated the renewal, and it will expire again in 90 days.

NET::ERR_CERT_COMMON_NAME_INVALID: right certificate, wrong name

The server presented a perfectly valid certificate for a name that is not the one the visitor typed. The classic pair is example.com and www.example.com: the certificate covers one, the visitor used the other. The second classic is a shared host that presents its own default certificate, so your visitor sees a certificate for host042.example.net and a warning.

List every name the certificate covers:

echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null | openssl x509 -noout -ext subjectAltName

The output looks like DNS:example.com, DNS:www.example.com. If the name the visitor typed is absent from that list, reissue with both names. For certbot, run certbot -d example.com -d www.example.com again and accept the expand prompt. For a hosting panel it is usually a checkbox called “include www” that was not ticked.

If the list shows a name you do not recognise, the server is not reading your certificate for that hostname. On nginx that means the server_name in the block holding ssl_certificate does not match, and nginx fell through to its default server. On Apache it is a <VirtualHost *:443> with no matching ServerName. Once the block is fixed and reloaded, the openssl line above should list your names.

Rule out one more cause: a DNS record pointing at the wrong box entirely. If example.com resolves to an old IP that still answers on 443 with a certificate from two years ago, reissuing on the new box will not help. dig +short example.com settles it in two seconds. /dns goes through how records go stale and what DNSSEC changes.

NET::ERR_CERT_AUTHORITY_INVALID: nobody the browser trusts signed it

Two different problems produce the same screen. The first is a self-signed certificate: the server signed it for itself and the browser has never heard of the signer. The second is a missing intermediate: a real authority signed it, and the server sends only the leaf, without the intermediate that links it back to a root the browser holds. Some browsers go and fetch the missing intermediate themselves and the page works. Others do not, so that visitor sees the warning while you see a working site.

The census counts 4,160,496 self-signed certificates among the 212,160,994 certificates it collected in the September edition. Some of those are deliberate, on internal hosts and appliances that were never meant for the public. The rest are servers still serving the placeholder certificate they shipped with.

Tell the two apart with the verify return code:

echo | openssl s_client -servername www.example.com -connect www.example.com:443 2>/dev/null | grep "Verify return code"

Verify return code: 18 (self-signed certificate) means exactly that: get a real certificate. Verify return code: 21 (unable to verify the first certificate) means the leaf is real and the chain is short.

On nginx, ssl_certificate must point at the full chain file (certbot names it fullchain.pem, and the mistake is pointing at cert.pem). On Apache 2.4.8 and later, SSLCertificateFile accepts the concatenated chain; older builds need a separate SSLCertificateChainFile. On a load balancer or CDN, there is a field for the chain or “CA bundle”, and it is easy to paste the certificate in and leave that field empty.

The two handshake errors: nothing was ever exchanged

ERR_SSL_VERSION_OR_CIPHER_MISMATCH

The browser and server could not agree on a protocol version or a cipher suite, and the connection ended before the server got as far as sending a certificate, so a new certificate will not help. The cause is almost always a server that only speaks TLS 1.0 or 1.1, which every mainstream browser dropped, or a configuration that pins one obscure cipher.

Do not confuse that with a server negotiating 1.2 rather than 1.3. Of the 212,154,494 domains where the census completed a TLS handshake, 200,260,207 (94.4%) negotiated TLS 1.3 and 11,894,287 (5.6%) negotiated TLS 1.2. TLS 1.1: 0 (0.00%). TLS 1.0: 0 (0.00%). A site on 1.2 works in every current browser and produces no warning of any kind; the mismatch error comes from servers that offer nothing the browser will accept.

Prove it by asking for each version in turn:

openssl s_client -connect www.example.com:443 -tls1_2 </dev/null 2>&1 | grep -E "Protocol|alert"
openssl s_client -connect www.example.com:443 -tls1_3 </dev/null 2>&1 | grep -E "Protocol|alert"

If both return alert handshake failure, the server config is the problem. On nginx set ssl_protocols TLSv1.2 TLSv1.3; and remove any ssl_ciphers line you do not understand. On Apache it is SSLProtocol -all +TLSv1.2 +TLSv1.3. Old appliances sometimes cannot be raised past 1.0; put those behind a reverse proxy that terminates TLS properly.

ERR_SSL_PROTOCOL_ERROR

The browser connected to port 443, sent a TLS ClientHello, and got back bytes that were not a TLS ServerHello. The Firefox name is more literal: SSL_ERROR_RX_RECORD_TOO_LONG, because what came back was usually an HTTP response, and its first line read as an impossibly long TLS record.

The usual cause is a web server block listening on 443 without ssl in the listen directive, so it answers plain HTTP on the secure port. The other common one is a firewall or proxy forwarding 443 to a backend’s port 80.

curl -sv https://www.example.com/ 2>&1 | grep -E "wrong version|SSL routines"

error:0A00010B:SSL routines::wrong version number confirms it. Check listen 443 ssl; on nginx, SSLEngine on inside the 443 VirtualHost on Apache, and whatever port mapping sits in front of the container.

NET::ERR_CERT_REVOKED and the warnings without a code

Revocation is rare and almost never your fault. An authority revokes a certificate when someone reports the key compromised, or when the authority finds it issued a whole batch against its own rules and pulls the lot. The fix is the same as expiry: reissue, which with certbot is certbot renew with its force renewal flag set. If nobody told you why, check the authority’s status page for a mass revocation before assuming your key leaked.

The warning with no code at all is the plain “Not secure” in the address bar on a page that otherwise loads. Two causes. Either the page is served over HTTP with no TLS in the picture, or it is served over HTTPS and then loads an image or a script over http://. Browsers block the script outright and mark the page for the image.

Open the browser console: Mixed Content: The page at 'https://www.example.com/' was loaded over HTTPS, but requested an insecure image 'http://...' names the exact resource. Search the codebase for http:// and fix each one, or add <meta http-equiv="Content-Security-Policy" content="upgrade-insecure-requests"> as a stopgap while you do. For the rest of the header set that stops a page being downgraded or framed, see /headers.

Check it before a visitor does

Of the 212,154,494 domains where the September census completed a TLS handshake, 189,117,751 (89.1%) presented a certificate that validated and 23,036,743 (10.9%) presented one that did not. Anyone who visits a domain in that second group sees a warning like the one in the screenshot. Per country and per TLD breakdowns of that figure are on the census data page.

The scan below runs the same checks against your domain: whether 443 answers, what version it negotiates, whether the certificate validates from a clean trust store, what names it covers, when it expires, and whether the chain is complete. It reports the error class by name, so you can match it against the table above.

Run it against the bare domain and the www name separately. A site that passes on one and fails on the other is the wrong name case, invisible from your own desk because you always type the form that works.

Make it stay fixed

Every certificate has a notAfter date, so set the renewal up to run without you, then verify the timer rather than trusting it: sudo certbot renew in dry-run mode exercises the whole path without issuing anything and tells you in plain text if the challenge would fail. Put a calendar reminder 14 days before notAfter for anything that renews by hand, because the issuer’s expiry email goes to whichever address signed up, and that is rarely the inbox anyone watches.

Monitor from outside your network with a clean trust store, because your own browser has cached the intermediate and trusts things a visitor’s does not. A free uptime checker with an expiry alert covers it.

Once the certificate is right and stays right, tell browsers to stop trying HTTP. A Strict-Transport-Security: max-age=31536000; includeSubDomains header means a returning visitor never sees a downgrade and never sees the “Not secure” page from a stray HTTP link. Get the certificate solid first, because HSTS with a broken certificate turns a warning the visitor can click through into one they cannot. /hsts walks through turning it on in the right order.

Then run the scan again and keep the output as the baseline.

Figures as of 5 September 2026, from the September 2026 edition of the defaults.exposed census. Census numbers move every month; the current values are on the census data page.