ERR_SSL_PROTOCOL_ERROR (tested)
net::ERR_SSL_PROTOCOL_ERROR means Chrome started a TLS handshake and got back something that is not valid TLS. We set up eight broken servers and loaded each in Chromium 153, curl and Node fetch. Only the non-TLS answers gave ERR_SSL_PROTOCOL_ERROR. Old TLS, bad ciphers, bad certificates and dropped connections all gave different errors.
1. What the error means
Chrome sends a TLS ClientHello and waits for a TLS ServerHello. If the bytes that come back are not a well-formed TLS record, Chrome stops and shows ERR_SSL_PROTOCOL_ERROR ("This site can't provide a secure connection").
In our tests that happened in exactly three cases. A plain HTTP server answered. A non-HTTP service answered (we used an SSH-style banner). Or the server sent a broken TLS record. In all three, the server is not speaking TLS on that port.
2. Our test setup
Each case was a small Node.js server on 127.0.0.1:8987. We opened https://localhost:8987/ and https://127.0.0.1:8987/ in headless Chromium with Playwright and recorded the net::ERR_ string from page.goto. Then we ran curl -sS https://localhost:8987/ and Node fetch() against the same server and recorded err.cause.code.
3. Results: cause → Chromium → curl → Node fetch
| Server on the port | Chromium 153 | curl 8.14.1 | Node fetch cause.code | Fix |
|---|---|---|---|---|
| Plain HTTP server (no TLS) | net::ERR_SSL_PROTOCOL_ERROR | exit 35, "TLS connect error: error:0A00010B:SSL routines::wrong version number" | ERR_SSL_WRONG_VERSION_NUMBER | Use http://, or the real HTTPS port |
| Non-HTTP service sends a text banner | net::ERR_SSL_PROTOCOL_ERROR | exit 35, "wrong version number" | ERR_SSL_WRONG_VERSION_NUMBER | Wrong port; point at the web server |
| Broken TLS record (handshake header, junk body) | net::ERR_SSL_PROTOCOL_ERROR | exit 35, "error:0A0000F4:SSL routines::unexpected message" | ERR_SSL_UNEXPECTED_MESSAGE | Fix the TLS terminator or middlebox |
| TLS 1.0/1.1 only | net::ERR_SSL_VERSION_OR_CIPHER_MISMATCH | exit 35, "error:0A00042E:SSL routines::tlsv1 alert protocol version" | ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION | Allow TLS 1.2 and 1.3 |
| No shared cipher (DHE-RSA-AES128-GCM-SHA256 only, TLS 1.2 max) | net::ERR_SSL_VERSION_OR_CIPHER_MISMATCH | exit 35, "error:0A000410:SSL routines::ssl/tls alert handshake failure" | ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILURE | Use default modern ciphers (ECDHE + AES-GCM / ChaCha20) |
| Server closes the socket after the ClientHello | net::ERR_CONNECTION_CLOSED | exit 35, "error:0A000126:SSL routines::unexpected eof while reading" | ECONNRESET ("Client network socket disconnected before secure TLS connection was established") | Check server logs, firewalls, SNI routing |
| Self-signed certificate | net::ERR_CERT_AUTHORITY_INVALID | exit 60, "SSL certificate problem: self-signed certificate" | DEPTH_ZERO_SELF_SIGNED_CERT | Use a trusted cert (or a local CA, e.g. mkcert) |
| Server asks for a client certificate (mTLS), none sent | net::ERR_BAD_SSL_CLIENT_AUTH_CERT (with cert errors ignored) | with -k: exit 56, "tlsv13 alert certificate required" | not measured | Send a client cert or drop requestCert |
So ERR_SSL_PROTOCOL_ERROR is narrow. If you see it, look for a port that is not serving TLS at all. An old TLS version or a cipher problem shows up as ERR_SSL_VERSION_OR_CIPHER_MISMATCH instead. Certificate problems are always ERR_CERT_*.
The server side differs too. For the plain HTTP case our Node TLS server logged nothing, because no TLS server was involved. For the cipher case it logged ERR_SSL_NO_SHARED_CIPHER; for TLS 1.0/1.1 it logged ERR_SSL_UNSUPPORTED_PROTOCOL.
4. The classic localhost cause: https:// to an http port
Most dev servers (Vite, webpack, Flask, Express) listen on plain HTTP. Typing https://localhost:3000 sends TLS to a server that answers in HTTP. That is the first row of the table.
$ curl -sS https://127.0.0.1:8987/
curl: (35) TLS connect error: error:0A00010B:SSL routines::wrong version number
$ curl -sS http://127.0.0.1:8987/
ok
"wrong version number" from curl or OpenSSL is the tell. The first bytes back were HTTP/1.1, not a TLS record header.
The reverse mistake looks different. http:// to a TLS port gave Chromium net::ERR_EMPTY_RESPONSE, curl exit 52 "Empty reply from server", and Node fetch UND_ERR_SOCKET ("other side closed").
5. HSTS can force https:// on dev domains
Sometimes you type http:// and still get this error. Chrome upgraded the URL to https before sending it. We mapped some names to 127.0.0.1 with --host-resolver-rules and loaded http://myapp.<tld>:8988/ against a plain HTTP server:
| URL typed | What Chromium 153 did | Result |
|---|---|---|
http://myapp.dev:8988/ | 307 Internal Redirect, Non-Authoritative-Reason: HSTS, to https | net::ERR_SSL_PROTOCOL_ERROR |
http://myapp.app:8988/ | Same HSTS redirect | net::ERR_SSL_PROTOCOL_ERROR |
http://myapp.foo:8988/ | Same HSTS redirect | net::ERR_SSL_PROTOCOL_ERROR |
http://myapp.test:8988/ | No redirect | 200 |
http://myapp.local:8988/ | No redirect | 200 |
http://localhost:8988/ | No redirect | 200 |
Whole TLDs like .dev, .app and .foo are on Chrome's HSTS preload list (see HSTS preload list requirements). You cannot turn that off for them. Use .test, .localhost or plain localhost for local HTTP dev, or serve real TLS.
A site can also teach Chrome HSTS for a single host with a Strict-Transport-Security header. HSTS is per host, not per port, so an HTTPS app on one port can force https on every other port of the same host. In our test we could not reproduce this for localhost: with an untrusted self-signed cert, Chromium did not keep the header even when told to ignore cert errors. Per the HSTS spec (RFC 6797), browsers ignore the header on connections with certificate errors. If you suspect a learned entry, query and delete it at chrome://net-internals/#hsts (from the Chrome docs, not measured).
6. Old TLS: OpenSSL refuses before Chrome does
We set minVersion: 'TLSv1' and maxVersion: 'TLSv1.1' on a Node 24 HTTPS server. With default settings, OpenSSL 3.5 on the server refused every handshake and logged ERR_SSL_NO_PROTOCOLS_AVAILABLE. TLS 1.0/1.1 only worked after adding ciphers: 'DEFAULT:@SECLEVEL=0', and then only for a client that also lowered its level:
$ openssl s_client -connect 127.0.0.1:8987 -tls1 -cipher 'DEFAULT:@SECLEVEL=0'
New, TLSv1.0, Cipher is ECDHE-RSA-AES256-SHA
$ curl --tlsv1.0 --tls-max 1.1 --ciphers 'DEFAULT:@SECLEVEL=0' -k https://127.0.0.1:8987/
ok
Chromium 153 never offered TLS 1.0 or 1.1, so it got ERR_SSL_VERSION_OR_CIPHER_MISMATCH. Without the special flags, curl and OpenSSL refuse too (curl said "no protocols available"). The fix is on the server: enable TLS 1.2 and 1.3.
# nginx
ssl_protocols TLSv1.2 TLSv1.3;
// Node.js
https.createServer({ key, cert, minVersion: 'TLSv1.2' }, app);
7. Checklist
- Run
curl -v https://host:port/. "wrong version number" means the port is not serving TLS. Tryhttp://. - Check the port. Dev servers, admin consoles and databases often sit next to each other. Make sure 443 (or your HTTPS port) really goes to the TLS listener.
- Behind a proxy or load balancer, check where TLS ends. If the proxy forwards raw TCP to an HTTP backend, the browser hits plain HTTP. See X-Forwarded-Proto in nginx for the usual setup.
- If the URL changed from http to https on its own, it is HSTS (section 5).
- If Chrome says
ERR_SSL_VERSION_OR_CIPHER_MISMATCHinstead, fix protocol versions and ciphers. If it saysERR_CERT_*, fix the certificate; see check SSL certificate expiry. - If it says
ERR_CONNECTION_CLOSEDor reset, the server dropped the handshake. See ERR_CONNECTION_RESET / ECONNRESET.
Frequently asked questions
What causes ERR_SSL_PROTOCOL_ERROR on localhost?
Almost always https:// pointed at a plain HTTP dev server. In our test Chromium 153 showed ERR_SSL_PROTOCOL_ERROR and curl said 'wrong version number' (exit 35). Use http://localhost, or run the dev server with TLS.
Does an old TLS version cause ERR_SSL_PROTOCOL_ERROR?
Not in our test. A server limited to TLS 1.0/1.1 gave net::ERR_SSL_VERSION_OR_CIPHER_MISMATCH in Chromium 153. A server with no shared cipher gave the same error.
Does a self-signed certificate cause ERR_SSL_PROTOCOL_ERROR?
No. Certificate problems give ERR_CERT_* errors. Our self-signed cert gave net::ERR_CERT_AUTHORITY_INVALID in Chromium, curl exit 60 and DEPTH_ZERO_SELF_SIGNED_CERT in Node fetch.
Why does Chrome go to https when I typed http?
HSTS. In our test http://myapp.dev, .app and .foo were rewritten by a 307 Internal Redirect with Non-Authoritative-Reason: HSTS, then failed with ERR_SSL_PROTOCOL_ERROR against a plain HTTP server. .test, .local and localhost were not rewritten.
How do I see the real error behind ERR_SSL_PROTOCOL_ERROR?
Run curl -v against the same URL. 'wrong version number' means the port is not speaking TLS. 'unexpected message' means a broken TLS answer. 'alert protocol version' or 'handshake failure' points to versions or ciphers.
Written with AI assistance. Measured on 2026-10-02 with headless Chromium 153.0.8010.12 (Playwright), curl 8.14.1 (OpenSSL 3.5.7), Node.js 24.21.0 fetch and an OpenSSL 3.5.7 CLI, against small Node.js test servers on 127.0.0.1. Points marked as from the docs were not measured. PlainHeaders is our extension; Chromium, curl, Node.js and OpenSSL are free.