Every browser certificate warning is one of about six problems, and the error code tells you which. NET::ERR_CERT_DATE_INVALID means the certificate is expired or the visitor's clock is wrong. NET::ERR_CERT_COMMON_NAME_INVALID means the certificate is for a different name. NET::ERR_CERT_AUTHORITY_INVALID means the browser cannot trace it back to an authority it trusts - a self-signed certificate, or a server that forgot to send its intermediate. ERR_SSL_PROTOCOL_ERROR means the two sides never got as far as looking at a certificate at all. And a padlock with "Not secure" next to a perfectly valid certificate is mixed content. Read the code, run one openssl command from a machine outside your network, and the fix is usually a single line of configuration.
This post is the troubleshooting half of the subject. How certificates are issued and renewed is in HTTPS and Let's Encrypt explained, and pointing a domain at a server so a certificate can be issued at all is in point a domain at your server and add SSL.
Read the error code before anything else#
The big red page is designed to scare visitors off, not to help you, so the useful part is small. In Chrome and Edge it is the grey code under the explanation, or behind "Advanced". In Firefox it is behind "Advanced" as well and has a different spelling. Safari mostly says "This Connection Is Not Private" and hides the reason behind "Show Details" and "view the certificate".
| Chrome / Edge | Firefox | What it means |
|---|---|---|
NET::ERR_CERT_DATE_INVALID | SEC_ERROR_EXPIRED_CERTIFICATE | Expired, not yet valid, or wrong clock |
NET::ERR_CERT_COMMON_NAME_INVALID | SSL_ERROR_BAD_CERT_DOMAIN | Certificate does not list this name |
NET::ERR_CERT_AUTHORITY_INVALID | SEC_ERROR_UNKNOWN_ISSUER | Untrusted issuer or missing intermediate |
NET::ERR_CERT_AUTHORITY_INVALID | MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT | Self-signed certificate |
ERR_SSL_PROTOCOL_ERROR | SSL_ERROR_RX_RECORD_TOO_LONG | Not TLS on that port, often plain HTTP |
ERR_SSL_VERSION_OR_CIPHER_MISMATCH | SSL_ERROR_NO_CYPHER_OVERLAP | No TLS version or cipher in common |
NET::ERR_CERT_REVOKED | SEC_ERROR_REVOKED_CERTIFICATE | The authority withdrew the certificate |
Two more things to note while you are on that page. First, whether there is a "Proceed anyway" link: when there is not, the site has HSTS set (or is on a preloaded HSTS domain such as anything under .dev or .app), and the browser refuses to let anyone click through. That is HSTS doing its job, not a separate fault. Second, who sees it. A warning that only one person gets is almost always their machine - clock, antivirus, a corporate proxy, hotel wifi. A warning everybody gets is the server.
Check from outside with openssl s_client#
The browser has caches, saved exceptions and its own copy of intermediates it picked up elsewhere. openssl s_client has none of that. It connects, does the handshake, prints exactly what the server sent, and tells you whether it verifies. Run it from a machine that is not on the same network as the server, because a local DNS override or a hosts-file entry will happily show you a different server than the world sees.
$ openssl s_client -connect example.com:443 -servername example.com </dev/null-servername matters. It sends SNI, the part of the handshake that tells a server hosting many sites which certificate you want. Without it you get whatever the server considers its default, which is often the wrong certificate and sends you chasing a problem that does not exist. Recent OpenSSL versions send SNI automatically from the -connect host, but older ones do not, so pass it every time. The </dev/null closes the connection as soon as the handshake is done instead of waiting for you to type an HTTP request.
The output is long. The three parts that matter:
Certificate chain 0 s:CN = example.com i:C = US, O = Let's Encrypt, CN = R11 1 s:C = US, O = Let's Encrypt, CN = R11 i:C = US, O = Internet Security Research Group, CN = ISRG Root X1...Verify return code: 0 (ok)The chain lists what the server sent: 0 is the leaf (your certificate), s: is the subject and i: is the issuer. Each certificate's issuer should be the next one's subject, ending at something a browser trusts. The intermediate names change over time, so do not worry if yours are not R11; worry if there is only a 0 line. The last line is the verdict. 0 (ok) means the chain checks out against your system's trust store. Anything else is the error, in OpenSSL's words.
To see the dates and names without the noise, pipe the certificate into openssl x509:
$ openssl s_client -connect example.com:443 -servername example.com </dev/null \ 2>/dev/null | openssl x509 -noout -subject -issuer -dates -ext subjectAltName-ext needs OpenSSL 1.1.1 or later. On an older system use -text and look for the X509v3 Subject Alternative Name block by eye.
The verify codes you will actually see:
| Code | Message | Meaning |
|---|---|---|
10 | certificate has expired | Renewal failed or was never set up |
18 | self-signed certificate | Leaf signed by itself |
19 | self-signed certificate in certificate chain | Private CA the client does not trust |
20 | unable to get local issuer certificate | Chain incomplete, or no trust store |
21 | unable to verify the first certificate | Server sent the leaf without its intermediate |
62 | hostname mismatch | Name not in the certificate (with -verify_hostname) |
Note that s_client does not check the name by default - it will say 0 (ok) for a valid certificate belonging to an entirely different domain. Add -verify_hostname example.com to make it check, or read the subjectAltName output yourself.
NET::ERR_CERT_DATE_INVALID: expired, or a clock that is wrong#
A certificate has a "not before" and a "not after" date, and outside that window it is invalid. There are two very different ways to land outside it.
The certificate really expired. If openssl x509 -dates shows a notAfter in the past, renewal failed. With Let's Encrypt certificates lasting 90 days, that means something has been failing quietly for about a month, because clients normally renew with 30 days to spare. The usual causes are the same ones that stop issuance in the first place: the domain no longer points at the server, port 80 was closed by a firewall change, a CDN was switched on in front of the origin, or the renewal timer was never installed because the first certificate was obtained by hand. Check the renewal log - for Certbot that is /var/log/letsencrypt/letsencrypt.log - and run certbot renew --dry-run to reproduce the failure on demand.
The other expired-certificate trap: renewal succeeded and nothing reloaded. Certbot writes the new files, but nginx and most other servers read certificates once at start-up and keep serving the old one from memory until they are reloaded. If the file on disk has a fresh date and s_client shows the old one, that is your problem. A deploy hook fixes it permanently:
$ certbot renew --deploy-hook "systemctl reload nginx"Shorter certificate lifetimes are coming across the whole industry, not just at Let's Encrypt - the CA/Browser Forum has agreed to step the maximum down over the next few years, ending at 47 days in 2029. Anything still renewed by a person with a calendar reminder will break more often every year. If a certificate on your system is renewed by hand, that is the one to automate first.
The visitor's clock is wrong. If only one person sees the error, and their browser says something like "Your clock is behind", the certificate is fine and their computer thinks it is a different year. This happens on machines with a dead motherboard battery, dual-boot systems where Windows and Linux disagree about whether the hardware clock is local time or UTC, and virtual machines restored from an old snapshot. Fixing the system time fixes every site at once. A site owner cannot fix this, and should not try.
Not yet valid. Rarer, but it happens: a brand-new certificate whose notBefore is a few seconds in the future relative to a server or client clock that drifts. Keep NTP running on servers - timedatectl should report System clock synchronized: yes.
NET::ERR_CERT_COMMON_NAME_INVALID: the certificate is for a different name#
The name in the address bar must appear in the certificate's Subject Alternative Name list. Browsers ignore the old Common Name field entirely - Chrome stopped reading it in 2017 - so a certificate that only has the name in CN fails even though it looks right at a glance. That mostly bites home-made certificates; every public authority puts names in the SAN list.
The common ways to end up here:
- `www` and the apex are different names. A certificate for
example.comdoes not coverwww.example.com, and the reverse is also true. Either get one certificate listing both, or redirect one to the other - and the redirect itself needs a valid certificate on the name it redirects from, because the browser checks the certificate before it ever sees the redirect. www vs apex domain and redirects covers choosing which one is canonical. - Wildcards cover exactly one label.
*.example.comcoversshop.example.combut notexample.comand notapi.shop.example.com. Wildcard certificates that "do not work" are almost always being asked to cover one of those two. - No SNI match on a shared server. When the server has no certificate configured for the name requested, it falls back to its default - which belongs to some other site on the same machine. In nginx that means the request landed in a
serverblock whoseserver_namedoes not include your host, so the firstlisten 443 sslblock (or the one markeddefault_server) answered instead. Checkserver_namefor typos, and runnginx -Tto see the configuration nginx actually loaded rather than the file you think you edited. The nginx reverse proxy guide has a working layout. - The DNS points at the wrong machine. You moved the site, the old A record is still there or still cached, and visitors are reaching the previous host, which has a certificate for something else entirely. Compare
dig +short example.com @1.1.1.1with the address you expect. DNS records explained covers the records involved. - Visiting by IP address.
https://203.0.113.10will never match a domain certificate. Use the name.
Incomplete chain: works in your browser, fails everywhere else#
This is the error that wastes the most time, because the person who set up the server cannot reproduce it. The certificate is valid, the name is right, the dates are fine, and the site loads in their browser. Meanwhile curl fails with curl: (60) SSL certificate problem: unable to get local issuer certificate, a Python script raises CERTIFICATE_VERIFY_FAILED, a payment provider's webhook refuses to deliver, and some phones show a warning.
A public authority does not sign your certificate with its root key, which is kept offline. It signs with an intermediate, and the intermediate is signed by the root. Browsers and operating systems ship the roots; they do not necessarily have the intermediate. So the server has to send both: your certificate first, then the intermediate. Send only the first and the client is holding a certificate signed by someone it has never heard of.
Desktop browsers often hide the mistake. Chrome and others will go and fetch a missing intermediate using a URL inside the certificate, or use one cached from another site that sent it properly. Command-line tools, programming-language HTTP libraries and many other clients do not, and fail. s_client shows it instantly: only a 0 line in the chain, and Verify return code: 21 (unable to verify the first certificate).
The fix is to serve the full chain file instead of the bare certificate. With Certbot that means fullchain.pem rather than cert.pem:
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;Apache 2.4.8 and later read the chain from the same file, so SSLCertificateFile should point at fullchain.pem too; the old SSLCertificateChainFile directive is deprecated. With a certificate bought from a commercial authority, you usually get your certificate and a "bundle" or "CA bundle" file separately. Concatenate them, your certificate first:
$ cat example_com.crt ca-bundle.crt > fullchain.crtOrder matters. If the intermediate comes first, nginx will complain that the key does not match the certificate, because it compares the key against the first certificate in the file. Do not include the root itself - clients have it already, and sending it only makes every handshake bigger.
Self-signed certificates and NET::ERR_CERT_AUTHORITY_INVALID#
A self-signed certificate is signed by its own key. It encrypts traffic exactly as well as any other, but nothing vouches for it, so every browser rejects it with NET::ERR_CERT_AUTHORITY_INVALID. There is nothing to fix on the browser side for a public site: get a real certificate. Let's Encrypt costs nothing and can be fully automated, so a self-signed certificate on a public domain in 2026 is a configuration that was meant to be temporary.
Where you do see them legitimately:
- A fresh install. Many control panels, routers, NAS boxes, Proxmox, and default web server packages generate a self-signed certificate at install time so HTTPS works at all. The warning is expected until you install a proper one.
- Local development. Generating one takes a single command:
$ openssl req -x509 -newkey rsa:2048 -nodes -days 365 \ -keyout localhost.key -out localhost.crt \ -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost"The -addext line is the part people leave out, and without it Chrome rejects the certificate on name grounds as well as trust grounds. For anything beyond a quick test, mkcert is easier: it creates a small local authority, installs it into your system and browser trust stores, and issues certificates that your own machine trusts with no warnings - and no other machine does.
- Internal services with a private CA. Companies run their own authority for internal names. Those certificates show
19 (self-signed certificate in certificate chain)ins_clienton any machine that has not had the company root installed. The fix is distributing the root, not disabling verification.
Two warnings about the shortcuts. Clicking "Proceed" teaches users to click "Proceed", which is precisely the habit an attacker needs. And curl -k, verify=False in Python requests, or NODE_TLS_REJECT_UNAUTHORIZED=0 turn off the check entirely - left in production code, they mean any machine in the path can read and alter the traffic. Point the client at the right CA file instead: curl --cacert, REQUESTS_CA_BUNDLE, or NODE_EXTRA_CA_CERTS.
ERR_SSL_PROTOCOL_ERROR: the handshake never finished#
Every error above means a certificate arrived and the browser disliked it. ERR_SSL_PROTOCOL_ERROR usually means no certificate arrived at all, because what answered on the port was not speaking TLS the way the browser expected.
Plain HTTP on the HTTPS port. By far the most common cause. A server block has listen 443; without ssl, an application is listening on 443 directly with no TLS configured, or you typed https:// in front of a port that only does HTTP, such as an app's own :3000 or :8080. Firefox's version of the error, SSL_ERROR_RX_RECORD_TOO_LONG, says it more honestly: it received an HTTP response and tried to read it as TLS. s_client reports wrong version number or packet length too long in its error lines. Check the listen directive:
listen 443 ssl;http2 on;http2 on; is the form from nginx 1.25.1 onwards; older versions write listen 443 ssl http2;. Either way, ssl has to be there.
No certificate configured for that name. Some servers, given an SNI name they have no certificate for and no default, abort the handshake rather than sending something wrong. The browser reports a protocol error. Same fix as the name-mismatch case: make the name exist in the configuration.
Old protocol versions only. Current browsers have dropped TLS 1.0 and 1.1. A server, appliance or old embedded device that only speaks those produces ERR_SSL_VERSION_OR_CIPHER_MISMATCH rather than a protocol error, but the test is the same:
$ openssl s_client -connect example.com:443 -servername example.com -tls1_2 </dev/null$ openssl s_client -connect example.com:443 -servername example.com -tls1_3 </dev/nullIf both fail, the server needs updating, and in nginx ssl_protocols TLSv1.2 TLSv1.3; is the line to set.
Something in the middle. Antivirus software with "HTTPS scanning", a corporate inspection proxy, and captive portals on hotel and train wifi all intercept TLS. Depending on how well they do it, the result is a protocol error or an authority error, and the certificate's issuer, when you can see it, is the antivirus vendor or the company rather than a public authority. If only one network or one machine has the problem, this is the first suspect. The page itself cannot fix it.
CDN in front, origin wrong. Behind Cloudflare, a broken origin shows up as the CDN's own error pages: 525 means the handshake between the CDN and your server failed, 526 means the CDN's "Full (strict)" mode found your origin certificate invalid. Its "Flexible" mode combined with an origin that redirects to HTTPS gives ERR_TOO_MANY_REDIRECTS instead. Cloudflare for websites and game servers covers which mode belongs with which origin.
Valid certificate, still "Not secure": mixed content#
When the certificate is fine but the padlock is missing, struck through, or replaced by "Not secure", the page loaded over HTTPS is pulling something in over plain HTTP. Scripts, stylesheets and iframes requested over http:// are blocked outright, which usually shows up as a page with missing styling or a broken menu rather than as an obvious error. Images and media are upgraded to HTTPS automatically by current Chrome, or blocked when the upgrade fails.
The browser console names every offending URL, which turns this from a hunt into a list. In Chrome it reads like:
Mixed Content: The page at 'https://example.com/' was loaded over HTTPS,but requested an insecure script 'http://example.com/js/menu.js'.This request has been blocked; the content must be served over HTTPS.The fix is to stop generating http:// URLs - usually old absolute URLs saved in a CMS database, a hard-coded asset host in a theme, or an application behind a reverse proxy that does not realise the visitor arrived over HTTPS and builds its links accordingly. That last one needs the app to trust X-Forwarded-Proto from the proxy. Both HTTPS and Let's Encrypt explained and point a domain at your server and add SSL cover the cleanup and the proxy settings, and migrate WordPress to a new host covers the database search-and-replace safely.
Where the certificate is handled for you#
Most of the failures above are about a person managing files: a renewal that broke, a reload nobody ran, a cert.pem where fullchain.pem belonged. On RE:NODE's web hosting and app hosting plans, a proxy slot does that part. You point an A record at the address shown, and the certificate is issued and renewed automatically, inside a 21-day window before expiry. Your application receives ordinary HTTP on its own port and the real visitor address in X-Forwarded-For, so it never holds a certificate and there is no chain to assemble.
What it does not change: DNS still lives at your domain provider, so a wrong or stale A record still produces a name or issuance problem, and mixed content is still the application's to fix. When something does look wrong, the s_client commands above work against any host, ours included.
FAQ#
Is it safe to click "Proceed anyway"?
On your own server, to confirm a self-signed test certificate, yes. On someone else's site, no: the warning means the browser could not confirm who it is talking to, and the most common legitimate causes look exactly like an interception. Never enter a password or card number after clicking through.
Why does the site work in Chrome but not in curl or my app?
The server is sending its certificate without the intermediate. Desktop browsers fill the gap themselves; most other clients do not. Serve the full chain file and the difference disappears.
Why does only one visitor see a certificate error?
Because the problem is on their side - a wrong system clock, antivirus HTTPS scanning, a workplace proxy or a public wifi login page. Ask them for the exact error code and the issuer shown in the certificate details.
How do I check when my certificate expires?
Run s_client piped into openssl x509 -noout -dates from outside your network, as shown above. notAfter is the expiry. For anything important, have a monitoring check alert when fewer than 14 days remain, so a broken renewal is noticed before visitors notice it.
Can I clear an HSTS error for a site I am working on?
In Chrome, chrome://net-internals/#hsts has a "Delete domain security policies" box that removes a site's HSTS entry from your own browser. It cannot remove a domain on the browser's built-in preload list, and the real fix is still a valid certificate.
Does a free certificate cause more errors than a paid one?
No. Browsers trust Let's Encrypt the same way they trust any commercial authority, and the errors in this post happen with paid certificates just as often - more often for missing chains, since the bundle file arrives separately.


