Short answer: ERR_SSL_PROTOCOL_ERROR means Chrome started a TLS handshake and got back something that is not valid TLS, so it stopped. In most cases the port behind the https:// address speaks plain HTTP: nginx with listen 443; but no ssl, a Docker port mapped to the wrong container port, or a dev server on localhost. If only you see it and other people do not, something on your device or network is interfering with HTTPS.
Two commands from any machine with curl tell you which side you are on:
# 1. Where does the handshake break?
curl -sv -o /dev/null https://example.com/
# 2. Does port 443 answer in plain HTTP?
curl -sS -i http://example.com:443/Replace example.com with the failing host. A port that really expects TLS rejects the second command: nginx and Cloudflare answer 400 The plain HTTP request was sent to HTTPS port, Apache You’re speaking plain HTTP to an SSL-enabled server port, Caddy and other Go servers Client sent an HTTP request to an HTTPS server. If you get your normal page or a redirect instead, port 443 speaks plain HTTP and you have found the bug: cause 1. The messages below come from curl 7.88 with OpenSSL 3.0 on Debian 12; the LibreSSL in macOS words them slightly differently.
Which message do you see?
| Client | Message | Usually means |
|---|---|---|
| Chrome, Edge | ERR_SSL_PROTOCOL_ERROR; in Chrome under This site can’t provide a secure connection and example.com sent an invalid response. | The reply was not TLS, or the handshake broke in an unusual way: plain HTTP on 443, another service, a filter |
| Chrome, Edge | localhost sent an invalid response. | Your dev server speaks HTTP, you asked for HTTPS: localhost |
| Firefox | Secure Connection Failed · SSL_ERROR_RX_RECORD_TOO_LONG | Plain HTTP on the HTTPS port |
| Firefox | PR_END_OF_FILE_ERROR | The connection was dropped mid-handshake: proxy, VPN, antivirus, filter |
| Safari | Safari can’t establish a secure connection to the server | Any TLS failure; run the curl test above |
| curl | curl: (35) … wrong version number | Plain HTTP or another protocol on the port |
| Chrome, Edge | uses an unsupported protocol. · ERR_SSL_VERSION_OR_CIPHER_MISMATCH | Not this error: no common TLS version or cipher |
| Chrome, Edge | Your connection is not private · NET::ERR_CERT_… | Not this error: the handshake worked and the certificate was rejected |
| Cloudflare page | Error 525 SSL handshake failed | The same fault between Cloudflare and your server: Cloudflare |
What the error actually means
When a handshake fails, Chromium translates the BoringSSL reason into a net::ERR_* code. A few reasons get their own code; everything else falls through to ERR_SSL_PROTOCOL_ERROR (code -107). It is a catch-all, which is why advice for it is so scattered.
The mapping below follows Chromium’s openssl_ssl_util.cc; the curl lines are real output, from example.com:80 and from local test endpoints that send exactly that reply:
| What the server sent | curl says | Chrome shows |
|---|---|---|
| Plain HTTP, or an SSH banner | wrong version number | ERR_SSL_PROTOCOL_ERROR |
| TLS alert access_denied (49), no client certificate asked for | tlsv1 alert access denied | ERR_SSL_PROTOCOL_ERROR; Chromium notes that some firewalls send it to block a page |
| TLS alert illegal_parameter (47) | sslv3 alert illegal parameter | ERR_SSL_PROTOCOL_ERROR |
| TLS alert handshake_failure (40) right after the ClientHello | sslv3 alert handshake failure | ERR_SSL_VERSION_OR_CIPHER_MISMATCH |
| TLS alert protocol_version (70), e.g. a server limited to TLS 1.0/1.1 | tlsv1 alert protocol version, or unsupported protocol when the server answers with an old ServerHello (with --tlsv1.2) | ERR_SSL_VERSION_OR_CIPHER_MISMATCH |
| TLS alert unrecognized_name (112) | tlsv1 unrecognized name | ERR_SSL_UNRECOGNIZED_NAME_ALERT |
| Nothing, connection closed | SSL_ERROR_SYSCALL | ERR_CONNECTION_CLOSED |
| A TCP reset | Recv failure: Connection reset by peer | ERR_CONNECTION_RESET |
curl 7.88 still offers TLS 1.0 and 1.1; against such a server OpenSSL 3.0 then stops with legacy sigalg disallowed or unsupported. Add --tlsv1.2 to test the way a browser connects. An expired certificate, an old TLS version or a wrong clock do not cause ERR_SSL_PROTOCOL_ERROR in current Chrome; they have codes of their own.
If only you see it: checks on your device
Open the address on your phone over mobile data. If it loads there, the cause is on your computer or your network:
- Incognito window and a second browser. Extensions are off in Incognito unless you allowed them. VPN and proxy extensions are frequent culprits, as is a system proxy in the OS network settings.
- Antivirus HTTPS scanning and company firewalls. They sit in the middle of every handshake, and when they choke, Chrome sees garbage. Switch off HTTPS scanning or SSL/TLS filtering for a test. On a site that works, click the icon left of the address, then Connection is secure and Certificate is valid: an issuer named after your antivirus or employer means your HTTPS is intercepted.
- Post-quantum key exchange through old inspection boxes. Since version 131, Chrome offers an ML-KEM post-quantum key share in its TLS 1.3 handshake. Per Fortinet’s known-issue note, FortiGate flow-based deep inspection with older IPS engines answers it with an illegal_parameter alert, which is
ERR_SSL_PROTOCOL_ERROR. The fix is a firewall update; Chrome’s policy to switch the key share off,PostQuantumKeyAgreementEnabled, ended with Chrome 146. - A hosts file entry. A line for the site in
/etc/hostsorC:\Windows\System32\drivers\etc\hosts, left over from a migration test, sends you to an old machine that may not speak TLS at all. Delete it. - QUIC. Disabling
chrome://flags/#enable-quicis a quick test, but QUIC failures normally show asERR_QUIC_PROTOCOL_ERROR, and Chrome falls back to TCP by itself. - A stale HSTS entry. If the site used HTTPS before and has dropped it, Chrome still forces HTTPS for it.
chrome://net-internals/#hsts(in Edgeedge://net-internals/#hsts) has a Delete domain security policies field; preloaded entries cannot be deleted there.
Site owner: reproduce it from outside
Test from outside, over both address families: a server can speak TLS on IPv4 and plain HTTP on IPv6, and then only some visitors see the error.
curl -4 -sS -o /dev/null -w '%{http_code}\n' https://example.com/
curl -6 -sS -o /dev/null -w '%{http_code}\n' https://example.com/A status code means that path works. For details, let OpenSSL do the handshake:
openssl s_client -connect example.com:443 -servername example.com </dev/null 2>&1 \
| grep -E 'error|handshake has read'40378B48D87F0000:error:0A00010B:SSL routines:ssl3_get_record:wrong version number:../ssl/record/ssl3_record.c:350:
SSL handshake has read 5 bytes and written 317 bytesread 5 bytes is the tell: OpenSSL read one record header, found HTTP/ where TLS should start, and gave up. A working server shows no error line and several kilobytes read, close to 4,000 for example.com:443.
Without a shell, our SSL checker runs a TLS handshake to port 443 from Amsterdam and shows the error instead of a certificate when none completes.
If the checker reports no connection at all, the open port checker tells you whether TCP 443 is reachable in the first place; a closed port gives ERR_CONNECTION_REFUSED, not this error.
Cause 1: port 443 answers in plain HTTP
nginx only speaks TLS on a socket whose listen line carries ssl. Without it, nginx serves plain HTTP on 443, even with ssl_certificate lines in the same block, and nginx -t does not complain. This block passes the test on nginx 1.22 from Debian 12:
server {
listen 443; # missing "ssl"
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
}The IPv6 line is the one people forget: listen [::]:443; next to a correct listen 443 ssl; is a separate socket, so IPv6 visitors get plain HTTP. List every 443 line without ssl in the configuration nginx actually loaded:
sudo nginx -T 2>/dev/null | grep -E '^\s*listen\s+(\S*:)?443\b' | grep -vE 'ssl|quic'No output is what you want. For each line it prints, add ssl, then test and reload:
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
root /var/www/example.com;
}sudo nginx -t && sudo systemctl reload nginxnginx also leaves a fingerprint. When a browser sends its TLS ClientHello to a plain-HTTP socket, the access log records a “request” that starts with the TLS record header \x16\x03\x01 and a status of 400:
sudo grep -c '\\x16\\x03\\x01' /var/log/nginx/access.logA few hits a day are scanners; a hit for every HTTPS visit means a 443 block speaks plain HTTP. Other stacks have the same trap:
- Apache: a
<VirtualHost *:443>copied from the port 80 one, withoutSSLEngine on. mod_ssl is off for every virtual host unless that line enables it; on Debian also runsudo a2enmod ssl. - Docker:
-p 443:80or"443:80"in Compose publishes the container’s plain-HTTP port on the host’s HTTPS port, shown bydocker psas0.0.0.0:443->80/tcp. Publish the app on127.0.0.1:8080:80and let a reverse proxy with a certificate own 443. - Node apps:
app.listen(443)in Express starts a plain HTTP server. Use thehttpsmodule, or better, run the app on a local port behind nginx.
Cause 2: the fix never loaded
A certificate and key that do not belong together cannot cause this error: nginx, Node and Python all refuse to load the pair. The trap is quieter. You fix the listen line, the reload fails, and nginx keeps serving the old configuration. The two usual messages from nginx -t (nginx 1.22, OpenSSL 3.0, example paths):
nginx: [emerg] SSL_CTX_use_PrivateKey("/etc/ssl/private/example.com.key") failed (SSL: error:05800074:x509 certificate routines::key values mismatch)
nginx: configuration file /etc/nginx/nginx.conf test failednginx: [emerg] no "ssl_certificate" is defined for the "listen ... ssl" directive in /etc/nginx/sites-enabled/example.com:1
nginx: configuration file /etc/nginx/nginx.conf test failedThe line number points at the start of the server block. A failed systemctl reload leaves the running nginx untouched; a restart with the same error leaves nginx stopped, which turns this error into ERR_CONNECTION_REFUSED. Always put nginx -t && in front of the reload and test from outside afterwards.
Cause 3: something other than a web server answers
Port 443 gets past strict networks, so it is often taken by something that is not HTTPS: SSH, an OpenVPN TCP server, a multiplexer such as sslh with a wrong default, or a router forwarding 443 to the wrong internal port. Real curl output against a local endpoint that answers with an OpenSSH banner:
curl: (35) OpenSSL/3.0.22: error:0A00010B:SSL routines::wrong version numberSee which process owns the port on the server:
sudo ss -ltnp 'sport = :443'The last column names the program, for example users:(("nginx",…)). If it is not your web server, or nothing is listed while the port still answers from outside, the traffic lands somewhere else: check the router’s port forwarding, the load balancer’s target port, the Docker mapping, and whether the A and AAAA records (dig +short example.com A, then AAAA) still name an old server.
Cause 4: HSTS sends browsers to HTTPS where there is none
Once a site has sent Strict-Transport-Security, the browser rewrites every http:// request for that host to https:// for as long as max-age says; with includeSubDomains, also for every internal tool on a subdomain.
The trap is the port. Chromium swaps only the scheme, so http://tools.example.com:8080/ becomes https://tools.example.com:8080/, and the plain-HTTP app on 8080 receives a TLS handshake: ERR_SSL_PROTOCOL_ERROR. DevTools shows the rewrite in the Network tab as 307 Internal Redirect with Non-Authoritative-Reason: HSTS. Only port 80 becomes 443.
Whole top-level domains are on Chrome’s HSTS preload list, among them .dev, .app and .page: anything under them only works over HTTPS. Serve TLS on that name or move the tool outside the policy. HSTS cannot be recalled quickly: browsers drop it when max-age runs out or when they fetch max-age=0 from that host over valid HTTPS.
Behind Cloudflare
With a proxied (orange-cloud) record, the browser’s TLS handshake ends at Cloudflare, not at your server. A broken origin then shows Cloudflare’s own error page, 525 SSL handshake failed or 526 Invalid SSL certificate, never ERR_SSL_PROTOCOL_ERROR. Check which case you are in:
dig +short example.com A
dig +short example.com AAAACloudflare addresses (listed at cloudflare.com/ips) mean this error is almost always on the visitor’s side. Your own server’s IP means the record is DNS only (grey cloud) or Cloudflare is paused.
That switch is where sites in Flexible mode fall over: Flexible talks plain HTTP to your server, so the origin never needed TLS on 443. Reached directly, it gives HTTPS visitors this error or ERR_CONNECTION_REFUSED. Install a real certificate on the origin and switch to Full (strict). A Cloudflare Origin CA certificate only works with the proxy on, because browsers do not trust it.
ERR_SSL_PROTOCOL_ERROR on localhost
localhost sent an invalid response almost always means the dev server speaks plain HTTP and the browser asked for HTTPS. Real output from a Python http.server on 127.0.0.1:18443, from curl and from the server’s log:
curl: (35) OpenSSL/3.0.22: error:0A00010B:SSL routines::wrong version number127.0.0.1 - - [12/Oct/2026 10:09:16] "\x16\x03\x01\x02\x00\x01\x00\x01…" 400 -Why the browser used HTTPS:
- You typed it, or a bookmark has it. Open
http://localhost:PORT. - The app redirects to HTTPS. Rails
config.force_ssl, DjangoSECURE_SSL_REDIRECTand ASP.NET Core’sUseHttpsRedirection()are meant for production; turn them off in the development config. - HSTS from an earlier project. Since version 132, Chrome ignores HSTS for
localhostand*.localhost. Older versions, and custom names likemyapp.test, keep it: delete the entry atchrome://net-internals/#hsts. A local name under.devor.appalways needs HTTPS; use.testor.localhostinstead.
If you need HTTPS in development, give the dev server a certificate. Next.js has next dev --experimental-https, which creates a self-signed one; mkcert creates locally trusted certificates for any dev server.
Not this error: version, cipher and certificate problems
A different code means the handshake got further. A server with only TLS 1.0 or 1.1, or no cipher Chrome accepts, gives ERR_SSL_VERSION_OR_CIPHER_MISMATCH. A certificate that is expired, self-signed or issued for another name gives a NET::ERR_CERT_* code under “Your connection is not private”.
Checklist after any HTTPS change
sudo nginx -tsaystest is successful, and the reload printed nothing.curl -sS -i http://example.com:443/is rejected with a 400, andcurl -4andcurl -6againsthttps://both return a status code, from outside the server.- Ports 80 and 443 are open in the firewall: port 443 and 80 covers opening and testing both.
On a VMHeaven Linux VPS you have root for every step here, and if an nginx or firewall change locks you out, the browser VNC console in the panel still reaches the server.
Frequently asked
Is ERR_SSL_PROTOCOL_ERROR my fault or the website's?
Open the site on another network, for example your phone on mobile data, or run 'curl -sv https://example.com/' from a server. If it fails everywhere, the site is broken, usually plain HTTP on port 443. If it fails only on your computer or network, look at antivirus HTTPS scanning, a company firewall, a VPN or a proxy.
How do I fix ERR_SSL_PROTOCOL_ERROR on localhost?
Your dev server speaks plain HTTP and the browser asked for HTTPS. Open http://localhost:PORT instead, turn off any HTTPS redirect in the app's development config, or give the dev server a certificate, for example with 'next dev --experimental-https' or a mkcert certificate.
Does clearing the cache or the SSL state fix ERR_SSL_PROTOCOL_ERROR?
Rarely. The cache stores pages, not handshakes, and 'Clear SSL state' in Windows Internet Options affects Windows' own TLS stack, not Chrome or Edge. What can help is deleting a stale HSTS entry at chrome://net-internals/#hsts when a host no longer serves HTTPS.