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 portChromium 153curl 8.14.1Node fetch cause.codeFix
Plain HTTP server (no TLS)net::ERR_SSL_PROTOCOL_ERRORexit 35, "TLS connect error: error:0A00010B:SSL routines::wrong version number"ERR_SSL_WRONG_VERSION_NUMBERUse http://, or the real HTTPS port
Non-HTTP service sends a text bannernet::ERR_SSL_PROTOCOL_ERRORexit 35, "wrong version number"ERR_SSL_WRONG_VERSION_NUMBERWrong port; point at the web server
Broken TLS record (handshake header, junk body)net::ERR_SSL_PROTOCOL_ERRORexit 35, "error:0A0000F4:SSL routines::unexpected message"ERR_SSL_UNEXPECTED_MESSAGEFix the TLS terminator or middlebox
TLS 1.0/1.1 onlynet::ERR_SSL_VERSION_OR_CIPHER_MISMATCHexit 35, "error:0A00042E:SSL routines::tlsv1 alert protocol version"ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSIONAllow 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_MISMATCHexit 35, "error:0A000410:SSL routines::ssl/tls alert handshake failure"ERR_SSL_SSL/TLS_ALERT_HANDSHAKE_FAILUREUse default modern ciphers (ECDHE + AES-GCM / ChaCha20)
Server closes the socket after the ClientHellonet::ERR_CONNECTION_CLOSEDexit 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 certificatenet::ERR_CERT_AUTHORITY_INVALIDexit 60, "SSL certificate problem: self-signed certificate"DEPTH_ZERO_SELF_SIGNED_CERTUse a trusted cert (or a local CA, e.g. mkcert)
Server asks for a client certificate (mTLS), none sentnet::ERR_BAD_SSL_CLIENT_AUTH_CERT (with cert errors ignored)with -k: exit 56, "tlsv13 alert certificate required"not measuredSend 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 typedWhat Chromium 153 didResult
http://myapp.dev:8988/307 Internal Redirect, Non-Authoritative-Reason: HSTS, to httpsnet::ERR_SSL_PROTOCOL_ERROR
http://myapp.app:8988/Same HSTS redirectnet::ERR_SSL_PROTOCOL_ERROR
http://myapp.foo:8988/Same HSTS redirectnet::ERR_SSL_PROTOCOL_ERROR
http://myapp.test:8988/No redirect200
http://myapp.local:8988/No redirect200
http://localhost:8988/No redirect200

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

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.